From 85741c8db3a7ed3c66b7dad04a8c96b6f4f10afb Mon Sep 17 00:00:00 2001 From: VajiraPrabuddhaka Date: Thu, 9 Jul 2026 13:27:07 +0530 Subject: [PATCH 1/2] feat: expose project deployment status per environment Signed-off-by: VajiraPrabuddhaka --- .../EnvironmentInfoService.test.ts | 96 +++++++++++++++++++ .../EnvironmentInfoService.ts | 92 +++++++++++++++++- .../project-release-binding.test.ts | 68 ++++++++++++- .../transformers/project-release-binding.ts | 31 ++++++ plugins/openchoreo-backend/src/types.ts | 10 ++ 5 files changed, 295 insertions(+), 2 deletions(-) diff --git a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.test.ts b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.test.ts index bffd7baea..26768c654 100644 --- a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.test.ts +++ b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.test.ts @@ -200,6 +200,102 @@ describe('EnvironmentInfoService', () => { ); }); + describe('projectDeploymentStatus', () => { + const makeProjectBinding = ( + env: string, + namespaceReady?: 'True' | 'False' | 'Unknown', + ) => ({ + metadata: { name: `my-project-${env}`, namespace: 'test-ns' }, + spec: { owner: { projectName: 'my-project' }, environment: env }, + status: { + conditions: namespaceReady + ? [{ type: 'NamespaceReady', status: namespaceReady }] + : [], + }, + }); + + // The component-env fetch issues GETs in this order: + // environments → releasebindings → project → pipeline → projectreleasebindings + const queueFirstFour = () => { + mockGET.mockResolvedValueOnce( + createOkResponse({ items: [k8sEnvironment], pagination: {} }), + ); + mockGET.mockResolvedValueOnce( + createOkResponse({ items: [k8sReleaseBinding] }), + ); + mockGET.mockResolvedValueOnce(createOkResponse(k8sProject)); + mockGET.mockResolvedValueOnce(createOkResponse(k8sPipeline)); + }; + + const run = () => + createService().fetchDeploymentInfo( + { + projectName: 'my-project', + componentName: 'api-service', + namespaceName: 'test-ns', + }, + 'token-123', + ); + + it('reports ready when the project binding NamespaceReady is True', async () => { + queueFirstFour(); + mockGET.mockResolvedValueOnce( + createOkResponse({ items: [makeProjectBinding('dev', 'True')] }), + ); + const result = await run(); + expect(result[0].projectDeploymentStatus).toBe('ready'); + }); + + it('reports ready even when a non-namespace project condition is False (keys off NamespaceReady, not aggregate Ready)', async () => { + queueFirstFour(); + mockGET.mockResolvedValueOnce( + createOkResponse({ + items: [ + { + metadata: { name: 'my-project-dev', namespace: 'test-ns' }, + spec: { + owner: { projectName: 'my-project' }, + environment: 'dev', + }, + status: { + conditions: [ + { type: 'NamespaceReady', status: 'True' }, + { type: 'ResourcesReady', status: 'False' }, + { type: 'Ready', status: 'False' }, + ], + }, + }, + ], + }), + ); + const result = await run(); + expect(result[0].projectDeploymentStatus).toBe('ready'); + }); + + it('reports pending when the project binding exists but NamespaceReady is not yet True', async () => { + queueFirstFour(); + mockGET.mockResolvedValueOnce( + createOkResponse({ items: [makeProjectBinding('dev', 'Unknown')] }), + ); + const result = await run(); + expect(result[0].projectDeploymentStatus).toBe('pending'); + }); + + it('reports not-deployed when the project has no binding for the env', async () => { + queueFirstFour(); + mockGET.mockResolvedValueOnce(createOkResponse({ items: [] })); + const result = await run(); + expect(result[0].projectDeploymentStatus).toBe('not-deployed'); + }); + + it('fails open to ready when the project-bindings fetch errors', async () => { + queueFirstFour(); + mockGET.mockResolvedValueOnce(createErrorResponse()); + const result = await run(); + expect(result[0].projectDeploymentStatus).toBe('ready'); + }); + }); + it('returns environments even when bindings fetch fails', async () => { mockGET.mockResolvedValueOnce( createOkResponse({ items: [k8sEnvironment], pagination: {} }), diff --git a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts index fb71d7ab8..37a562c12 100644 --- a/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts +++ b/plugins/openchoreo-backend/src/services/EnvironmentService/EnvironmentInfoService.ts @@ -24,6 +24,10 @@ import { transformReleaseBinding, } from '../transformers'; import { deriveBindingStatusDetailed } from '../transformers/release-binding'; +import { + deriveProjectDeploymentStatus, + type ProjectDeploymentStatus, +} from '../transformers/project-release-binding'; type ModelsEnvironment = EnvironmentResponse; @@ -305,12 +309,26 @@ export class EnvironmentInfoService implements EnvironmentService { transformReleaseBinding, ); + // Enrich with per-env project-deployment status: whether the owning + // project is deployed in each environment (its cell namespace exists), + // which a component requires before it can run there. Fetched after the + // main batch — it's a fail-open enrichment, so serialising it keeps the + // hot path's parallel fetches intact and never blocks a deploy on a + // transient error. `null` (any failure, incl. a 403) → treat every env + // as project-deployed. + const projectBindings = await this.fetchProjectBindingsForStatus( + client, + request.namespaceName, + request.projectName, + ); + // Transform environment data with bindings and promotion information const transformStart = Date.now(); const result = this.transformEnvironmentDataWithBindings( environments, bindings, deploymentPipeline, + projectBindings, ); const transformEnd = Date.now(); @@ -346,6 +364,9 @@ export class EnvironmentInfoService implements EnvironmentService { environmentData: ModelsEnvironment[], bindings: ReleaseBindingResponse[], deploymentPipeline: any | null, + // Owning project's ProjectReleaseBindings, or `null` when the fetch + // failed (fail-open — every env is treated as project-deployed). + projectBindings: NewProjectReleaseBinding[] | null, ): Environment[] { // Create maps for easy lookup const envMap = new Map(); @@ -374,6 +395,30 @@ export class EnvironmentInfoService implements EnvironmentService { bindingsByEnv.set(envName, binding); } + // Build project-binding map by environment. `null` (fetch failed) means + // "unknown → treat every env as project-deployed" so we never block on a + // transient error; a non-null (possibly empty) list means an env with no + // entry is genuinely not project-deployed. + const projectBindingsByEnv = + projectBindings === null + ? null + : (() => { + const map = new Map(); + for (const pb of projectBindings) { + const envRef = pb.spec?.environment; + if (!envRef) continue; + const envName = envNameMap.get(envRef.toLowerCase()) || envRef; + map.set(envName, pb); + } + return map; + })(); + const projectDeploymentStatusFor = ( + envName: string, + ): ProjectDeploymentStatus => + projectBindingsByEnv === null + ? 'ready' + : deriveProjectDeploymentStatus(projectBindingsByEnv.get(envName)); + // A resolved pipeline with no promotion paths defines no deployable // environments — return an empty list if ( @@ -427,6 +472,7 @@ export class EnvironmentInfoService implements EnvironmentService { envData, binding, promotionTargets, + projectDeploymentStatusFor(envName), ); orderedEnvironments.push(transformedEnv); @@ -439,7 +485,8 @@ export class EnvironmentInfoService implements EnvironmentService { private createEnvironmentFromBinding( envData: ModelsEnvironment, binding: ReleaseBindingResponse | undefined, - promotionTargets?: any[], + promotionTargets: any[] | undefined, + projectDeploymentStatus: ProjectDeploymentStatus, ): Environment { const envName = envData.displayName || envData.name; const envResourceName = envData.name; // Actual Kubernetes resource name @@ -471,6 +518,7 @@ export class EnvironmentInfoService implements EnvironmentService { name: envName, resourceName: envResourceName, bindingName: binding?.name, + projectDeploymentStatus, hasComponentTypeOverrides: binding?.componentTypeEnvironmentConfigs && Object.keys(binding.componentTypeEnvironmentConfigs).length > 0, @@ -502,6 +550,48 @@ export class EnvironmentInfoService implements EnvironmentService { return transformedEnv; } + /** + * Fetches the owning project's ProjectReleaseBindings for the per-env + * project-deployment status enrichment. Self-contained and fail-open: + * returns `null` on ANY failure (including a 403 on the bindings resource), + * which {@link transformEnvironmentDataWithBindings} treats as "project + * deployed in every env" so the deploy UI never blocks on a transient or + * authz error. + */ + private async fetchProjectBindingsForStatus( + client: ReturnType, + namespaceName: string, + projectName: string, + ): Promise { + try { + const { data, error, response } = await client.GET( + '/api/v1/namespaces/{namespaceName}/projectreleasebindings', + { + params: { + path: { namespaceName }, + query: { project: projectName }, + }, + }, + ); + if (error || !response.ok || !data) { + this.logger.warn( + `Project-deployment check: bindings fetch for project "${projectName}" ` + + `returned status ${response?.status}; treating project as deployed (fail-open).`, + ); + return null; + } + return (((data as any).items ?? []) as NewProjectReleaseBinding[]).filter( + b => (b as any)?.spec?.owner?.projectName === projectName, + ); + } catch (e) { + this.logger.warn( + `Project-deployment check: failed to fetch bindings for project "${projectName}": ${e}; ` + + `treating project as deployed (fail-open).`, + ); + return null; + } + } + private getEnvironmentOrder( promotionPaths: any[], envNameMap: Map, diff --git a/plugins/openchoreo-backend/src/services/transformers/project-release-binding.test.ts b/plugins/openchoreo-backend/src/services/transformers/project-release-binding.test.ts index c2c72b50d..4f9d9ae17 100644 --- a/plugins/openchoreo-backend/src/services/transformers/project-release-binding.test.ts +++ b/plugins/openchoreo-backend/src/services/transformers/project-release-binding.test.ts @@ -1,5 +1,8 @@ import type { OpenChoreoComponents } from '@openchoreo/openchoreo-client-node'; -import { transformProjectReleaseBinding } from './project-release-binding'; +import { + transformProjectReleaseBinding, + deriveProjectDeploymentStatus, +} from './project-release-binding'; type ProjectReleaseBinding = OpenChoreoComponents['schemas']['ProjectReleaseBinding']; @@ -102,3 +105,66 @@ describe('transformProjectReleaseBinding', () => { expect(condition.message).toBe('still working'); }); }); + +describe('deriveProjectDeploymentStatus', () => { + const withConditions = ( + conditions: Array<{ type: string; status: string }>, + ) => makeBinding({ status: { conditions } as any }); + + it('returns not-deployed when there is no binding', () => { + expect(deriveProjectDeploymentStatus(undefined)).toBe('not-deployed'); + }); + + it('returns ready when NamespaceReady is True', () => { + expect( + deriveProjectDeploymentStatus( + withConditions([{ type: 'NamespaceReady', status: 'True' }]), + ), + ).toBe('ready'); + }); + + it('keys off NamespaceReady, not the aggregate Ready — ready even when ResourcesReady is False', () => { + expect( + deriveProjectDeploymentStatus( + withConditions([ + { type: 'NamespaceReady', status: 'True' }, + { type: 'ResourcesReady', status: 'False' }, + { type: 'Ready', status: 'False' }, + ]), + ), + ).toBe('ready'); + }); + + it('returns pending when NamespaceReady is False', () => { + expect( + deriveProjectDeploymentStatus( + withConditions([{ type: 'NamespaceReady', status: 'False' }]), + ), + ).toBe('pending'); + }); + + it('returns pending when NamespaceReady is Unknown (unpinned ProjectReleaseNotSet)', () => { + expect( + deriveProjectDeploymentStatus( + withConditions([ + { type: 'Synced', status: 'False' }, + { type: 'NamespaceReady', status: 'Unknown' }, + ]), + ), + ).toBe('pending'); + }); + + it('returns pending when NamespaceReady condition is absent', () => { + expect( + deriveProjectDeploymentStatus( + withConditions([{ type: 'Ready', status: 'True' }]), + ), + ).toBe('pending'); + }); + + it('returns pending when the binding has no status/conditions yet', () => { + expect( + deriveProjectDeploymentStatus(makeBinding({ status: undefined })), + ).toBe('pending'); + }); +}); diff --git a/plugins/openchoreo-backend/src/services/transformers/project-release-binding.ts b/plugins/openchoreo-backend/src/services/transformers/project-release-binding.ts index 449115469..c6707f2d8 100644 --- a/plugins/openchoreo-backend/src/services/transformers/project-release-binding.ts +++ b/plugins/openchoreo-backend/src/services/transformers/project-release-binding.ts @@ -9,6 +9,37 @@ import { deriveBindingStatusDetailed } from './release-binding'; type NewProjectReleaseBinding = OpenChoreoComponents['schemas']['ProjectReleaseBinding']; +/** + * Whether a project is deployed enough in an environment for a component of + * that project to be deployable there. A component's ReleaseBinding applies + * its manifests into the project's cell namespace, which is created by the + * project's ProjectReleaseBinding — so the gating signal is the binding's + * `NamespaceReady` condition, NOT the aggregate `Ready` (which also folds in + * `ResourcesReady` and would wrongly report a project as undeployed when some + * unrelated project resource is degraded). + * + * - `not-deployed` — no ProjectReleaseBinding exists for the environment. + * - `ready` — the binding's `NamespaceReady` condition is `True` (cell + * namespace exists on the data plane). + * - `pending` — a binding exists but `NamespaceReady` is not yet `True` + * (`False` / `Unknown` / absent). Covers the unpinned + * `Synced=False / ProjectReleaseNotSet` case, where the controller sets + * `NamespaceReady=Unknown` until the pin is seeded and the namespace lands. + */ +export type ProjectDeploymentStatus = 'ready' | 'pending' | 'not-deployed'; + +export function deriveProjectDeploymentStatus( + binding: NewProjectReleaseBinding | undefined, +): ProjectDeploymentStatus { + if (!binding) return 'not-deployed'; + const namespaceReady = ( + binding.status?.conditions as + | Array<{ type?: string; status?: string }> + | undefined + )?.find(c => c.type === 'NamespaceReady'); + return namespaceReady?.status === 'True' ? 'ready' : 'pending'; +} + /** * Transforms a K8s-style ProjectReleaseBinding into the flat * ProjectReleaseBindingResponse shape expected by the frontend. Reuses the diff --git a/plugins/openchoreo-backend/src/types.ts b/plugins/openchoreo-backend/src/types.ts index 96a35225d..2f3e017ae 100644 --- a/plugins/openchoreo-backend/src/types.ts +++ b/plugins/openchoreo-backend/src/types.ts @@ -101,6 +101,16 @@ export interface Environment { name: string; resourceName?: string; bindingName?: string; + /** + * Whether the owning project is deployed in this environment (its cell + * namespace exists), which a component requires before it can run here. + * Derived from the project's ProjectReleaseBinding `NamespaceReady` + * condition. `ready` — namespace exists; `pending` — binding exists but the + * namespace isn't ready yet; `not-deployed` — no project binding for this + * env. Absent when the project-deployment check could not run (treated as + * deployed by the UI, fail-open). + */ + projectDeploymentStatus?: 'ready' | 'pending' | 'not-deployed'; hasComponentTypeOverrides?: boolean; dataPlaneRef?: string; dataPlaneKind?: 'DataPlane' | 'ClusterDataPlane'; From db3792c037135426c95ed145c02d7df22e1fb949 Mon Sep 17 00:00:00 2001 From: VajiraPrabuddhaka Date: Thu, 9 Jul 2026 13:27:17 +0530 Subject: [PATCH 2/2] feat: block component deploy when the project is not deployed Signed-off-by: VajiraPrabuddhaka --- .changeset/component-deploy-project-prereq.md | 6 + .../components/Environments/Environments.tsx | 18 ++- .../PipelineDAG/DeployFlowCanvas.tsx | 7 + .../DeploymentErrorDetailsDialog.test.tsx | 32 ++++ .../DeploymentErrorDetailsDialog.tsx | 27 +++- .../DeploymentFailureBanner.test.tsx | 15 ++ .../components/DeploymentFailureBanner.tsx | 20 +++ .../EnvironmentDetailPanel.test.tsx | 25 ++++ .../components/EnvironmentDetailPanel.tsx | 76 ++++++++-- .../components/MiniEnvironmentNode.tsx | 13 +- .../ProjectNotDeployedCallout.test.tsx | 81 +++++++++++ .../components/ProjectNotDeployedCallout.tsx | 102 +++++++++++++ .../components/PromotePrimaryAction.tsx | 10 +- .../components/SetupDetailPane.test.tsx | 81 +++++++++++ .../components/SetupDetailPane.tsx | 46 +++++- .../Environments/hooks/useEnvironmentData.ts | 8 + .../hooks/usePromotionAction.test.ts | 26 ++++ .../Environments/hooks/usePromotionAction.ts | 25 +++- .../utils/projectDeployment.test.ts | 137 ++++++++++++++++++ .../Environments/utils/projectDeployment.ts | 75 ++++++++++ .../ProjectEnvironmentsList.test.tsx | 35 +++++ .../ProjectEnvironmentsList.tsx | 37 ++++- 22 files changed, 876 insertions(+), 26 deletions(-) create mode 100644 .changeset/component-deploy-project-prereq.md create mode 100644 plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.test.tsx create mode 100644 plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.tsx create mode 100644 plugins/openchoreo/src/components/Environments/utils/projectDeployment.test.ts create mode 100644 plugins/openchoreo/src/components/Environments/utils/projectDeployment.ts diff --git a/.changeset/component-deploy-project-prereq.md b/.changeset/component-deploy-project-prereq.md new file mode 100644 index 000000000..705a20d46 --- /dev/null +++ b/.changeset/component-deploy-project-prereq.md @@ -0,0 +1,6 @@ +--- +'@openchoreo/backstage-plugin': minor +'@openchoreo/backstage-plugin-backend': minor +--- + +Block deploying a component to an environment where its project is not deployed, and guide the user to deploy the project first. diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index 34013d02f..293fdb49a 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useNotification } from '../../hooks'; @@ -91,6 +91,22 @@ export const Environments = ({ // Polling for pending deployments useEnvironmentPolling(isPending, refetch); + // Refetch when the user returns to this browser tab. The "Deploy project in + // project view" hand-off opens the project's Deploy tab in another tab; on + // return, refetching lifts the project-not-deployed block (deploy/promote + // re-enables) without a manual refresh. + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible') refetch(); + }; + window.addEventListener('focus', onVisible); + document.addEventListener('visibilitychange', onVisible); + return () => { + window.removeEventListener('focus', onVisible); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [refetch]); + // Check if workload editor is supported const isWorkloadEditorSupported = useMemo( () => diff --git a/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx b/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx index b398132d1..f1a1bd349 100644 --- a/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx +++ b/plugins/openchoreo/src/components/Environments/PipelineDAG/DeployFlowCanvas.tsx @@ -23,6 +23,7 @@ import { useDeployFlowCanvasStyles } from '../styles'; import { MiniEnvironmentNode } from '../components/MiniEnvironmentNode'; import { SetupCard } from '../components/SetupCard'; import { useEnvironmentsContext } from '../EnvironmentsContext'; +import { makeIsTargetProjectBlocked } from '../utils/projectDeployment'; import type { ActionTrackers, Environment } from '../types'; const SETUP_NODE_ID = '__setup__'; @@ -154,6 +155,11 @@ export const DeployFlowCanvas: FC = ({ return map; }, [environments]); + const isTargetProjectBlocked = useMemo( + () => makeIsTargetProjectBlocked(environments), + [environments], + ); + if (!layout) { return null; } @@ -253,6 +259,7 @@ export const DeployFlowCanvas: FC = ({ selected={selectedEnvName === env.name} isRefreshing={refreshingEnvName(env.name)} isAlreadyPromoted={target => isAlreadyPromoted(env, target)} + isTargetProjectBlocked={isTargetProjectBlocked} actionTrackers={actionTrackers} activeIncidentCount={ incidentsSummaries?.get(env.name)?.activeCount diff --git a/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.test.tsx b/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.test.tsx index 0bd16f703..e5464c59c 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.test.tsx @@ -1,5 +1,8 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockComponentEntity } from '@openchoreo/test-utils'; import { DeploymentErrorDetailsDialog } from './DeploymentErrorDetailsDialog'; describe('DeploymentErrorDetailsDialog', () => { @@ -56,4 +59,33 @@ describe('DeploymentErrorDetailsDialog', () => { screen.getByText(/could not roll out this release/i), ).toBeInTheDocument(); }); + + it('renders the project-not-deployed remediation callout when attributed', () => { + render( + + + + + , + ); + expect(screen.getByText('Project not deployed')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /deploy project/i }), + ).toHaveAttribute( + 'href', + '/catalog/default/system/test-1/deploy?env=development&intent=deploy', + ); + }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.tsx b/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.tsx index 002040bd3..c9c5ff381 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeploymentErrorDetailsDialog.tsx @@ -10,6 +10,7 @@ import { } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined'; +import { ProjectNotDeployedCallout } from './ProjectNotDeployedCallout'; const useStyles = makeStyles(theme => ({ reasonChip: { @@ -46,6 +47,13 @@ export interface DeploymentErrorDetailsDialogProps { reason?: string; /** The full controller failure message. */ message?: string; + /** + * When true, the failure is attributed to the project not being deployed in + * this environment — render the remediation callout below the raw error. + */ + projectNotDeployed?: boolean; + envName?: string; + envResourceName?: string; } /** @@ -56,7 +64,15 @@ export interface DeploymentErrorDetailsDialogProps { */ export const DeploymentErrorDetailsDialog: FC< DeploymentErrorDetailsDialogProps -> = ({ open, onClose, reason, message }) => { +> = ({ + open, + onClose, + reason, + message, + projectNotDeployed, + envName, + envResourceName, +}) => { const classes = useStyles(); const fullMessage = message || 'The controller could not roll out this release.'; @@ -92,6 +108,15 @@ export const DeploymentErrorDetailsDialog: FC< {fullMessage} + {projectNotDeployed && envName && ( + + + + )} diff --git a/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.test.tsx b/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.test.tsx index 6c0380ecb..aaf39fe7e 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.test.tsx @@ -78,4 +78,19 @@ describe('DeploymentFailureBanner', () => { await user.click(screen.getByRole('button', { name: /close/i })); await waitForElementToBeRemoved(() => screen.queryByRole('dialog')); }); + + it('prepends the plain-language lead line when the failure is the project not being deployed', () => { + render( + , + ); + expect( + screen.getByText(/The project isn't deployed to this environment/i), + ).toBeInTheDocument(); + }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.tsx b/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.tsx index e249cb750..ec1b76948 100644 --- a/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.tsx +++ b/plugins/openchoreo/src/components/Environments/components/DeploymentFailureBanner.tsx @@ -70,6 +70,15 @@ export interface DeploymentFailureBannerProps { message?: string; /** Machine-readable reason, e.g. `RenderingFailed` / `AutoDeployFailed`. */ reason?: string; + /** + * When true, the failure is attributed to the project not being deployed in + * this environment. Prepends a plain-language lead line and threads the + * remediation into the details dialog. `envName` / `envResourceName` feed + * the "deploy the project" deep link. + */ + projectNotDeployed?: boolean; + envName?: string; + envResourceName?: string; } /** @@ -86,6 +95,9 @@ export interface DeploymentFailureBannerProps { export const DeploymentFailureBanner = ({ message, reason, + projectNotDeployed, + envName, + envResourceName, }: DeploymentFailureBannerProps) => { const classes = useStyles(); const [detailsOpen, setDetailsOpen] = useState(false); @@ -102,6 +114,11 @@ export const DeploymentFailureBanner = ({ + {projectNotDeployed && ( + + The project isn't deployed to this environment. + + )} setDetailsOpen(false)} reason={reason} message={message} + projectNotDeployed={projectNotDeployed} + envName={envName} + envResourceName={envResourceName} /> ); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx index 748b40d22..5d728ed6a 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.test.tsx @@ -882,4 +882,29 @@ describe('EnvironmentDetailPanel', () => { }); expect(screen.getByTestId('browser-dialog')).toBeInTheDocument(); }); + + it('attributes a namespace-not-found failure to the project and shows the remediation (S2)', () => { + renderPanel({ + selection: { + kind: 'env', + environment: { + name: 'Development', + resourceName: 'development', + bindingName: 'my-component-development', + projectDeploymentStatus: 'not-deployed', + endpoints: [], + deployment: { + status: 'Failed', + statusReason: 'ResourceApplyFailed', + statusMessage: 'namespaces "dp-x" not found', + }, + } as Environment, + }, + }); + + expect( + screen.getByText(/The project isn't deployed to this environment/i), + ).toBeInTheDocument(); + expect(screen.getByText('Project not deployed')).toBeInTheDocument(); + }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx index f7deb38a9..47128e8e6 100644 --- a/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx +++ b/plugins/openchoreo/src/components/Environments/components/EnvironmentDetailPanel.tsx @@ -44,11 +44,16 @@ import { IncidentsBanner } from './IncidentsBanner'; import { DeploymentFailureBanner } from './DeploymentFailureBanner'; import { InvokeUrlsDialog } from './InvokeUrlsDialog'; import { PromotePrimaryAction } from './PromotePrimaryAction'; +import { ProjectNotDeployedCallout } from './ProjectNotDeployedCallout'; import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; import { ReleaseManifestDialog } from './ReleaseManifestDialog'; import type { ReleaseDeployments } from './releaseFormatters'; import { RemoveDeploymentConfirmationDialog } from './RemoveDeploymentConfirmationDialog'; import { SetupDetailPane } from './SetupDetailPane'; +import { + attributeToProjectNotDeployed, + makeIsTargetProjectBlocked, +} from '../utils/projectDeployment'; import type { ActionTrackers, Environment } from '../types'; export type DetailPanelSelection = @@ -139,6 +144,11 @@ export const EnvironmentDetailPanel = ({ const [diffOpen, setDiffOpen] = useState(false); const { entity } = useEntity(); const { environments, renderInvestigateAction } = useEnvironmentsContext(); + // A promote target whose project is not deployed there blocks the promote. + const isTargetProjectBlocked = useMemo( + () => makeIsTargetProjectBlocked(environments), + [environments], + ); const { releases, loading: releasesLoading } = useReleases(entity); const deployments: ReleaseDeployments = useMemo(() => { const map: ReleaseDeployments = {}; @@ -204,6 +214,14 @@ export const EnvironmentDetailPanel = ({ return ready?.message; })(); + // When the failure is the component's project not being deployed in this + // env (namespace missing), attribute it so the banner/dialog explain the + // real cause and offer to deploy the project. + const projectNotDeployedFailure = + isBindingFailed && + !!environment && + attributeToProjectNotDeployed(environment); + const failureBanner = isBindingFailed ? { message: bindingFailureMessage, @@ -372,7 +390,19 @@ export const EnvironmentDetailPanel = ({ + {projectNotDeployedFailure && ( + + + + )} )} {showReleaseSection && ( @@ -582,19 +612,39 @@ export const EnvironmentDetailPanel = ({ Actions {showPromote && ( - - - + <> + + + + {(() => { + // When a promote target's project isn't deployed there, + // the promote is disabled — surface the fix inline, + // targeting the first blocked target. + const blocked = (environment.promotionTargets ?? []).find( + t => isTargetProjectBlocked(t), + ); + return blocked ? ( + + + + ) : null; + })()} + )} boolean; + /** Reports whether a promote target env's project is undeployed (blocks it). */ + isTargetProjectBlocked?: (target: { + name: string; + resourceName?: string; + }) => boolean; actionTrackers: ActionTrackers; /** * Active-incident count from useIncidentsSummary. Undefined when @@ -66,6 +71,7 @@ export const MiniEnvironmentNode = ({ selected, isRefreshing, isAlreadyPromoted, + isTargetProjectBlocked, actionTrackers, activeIncidentCount, onSelect, @@ -101,6 +107,7 @@ export const MiniEnvironmentNode = ({ statusReason: environment.deployment.statusReason, promotionTargets: environment.promotionTargets, isAlreadyPromoted, + isTargetProjectBlocked, promotionTracker: actionTrackers.promotionTracker, suspendTracker: actionTrackers.suspendTracker, onPromote, @@ -537,7 +544,8 @@ function PromotePrimaryButton({ const { canPromote, loading, deniedTooltip } = usePromoteToEnvPermission(targetEnvName); const disabled = action.disabled || loading || !canPromote; - const tooltip = !canPromote && !loading ? deniedTooltip : ''; + const tooltip = + action.blockedReason || (!canPromote && !loading ? deniedTooltip : ''); return ( @@ -577,7 +585,8 @@ function PromoteMenuItemRow({ const { canPromote, loading, deniedTooltip } = usePromoteToEnvPermission(targetEnvName); const disabled = action.disabled || loading || !canPromote; - const tooltip = !canPromote && !loading ? deniedTooltip : ''; + const tooltip = + action.blockedReason || (!canPromote && !loading ? deniedTooltip : ''); return ( diff --git a/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.test.tsx b/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.test.tsx new file mode 100644 index 000000000..b5499a5ec --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { mockComponentEntity } from '@openchoreo/test-utils'; +import { ProjectNotDeployedCallout } from './ProjectNotDeployedCallout'; + +const renderCallout = ( + props: Partial> = {}, + entityOverrides = {}, +) => + render( + + + + + , + ); + +describe('ProjectNotDeployedCallout', () => { + it('links to the project Deploy tab in a new tab, deep-linked at the env', () => { + renderCallout(); + const link = screen.getByRole('link', { + name: /deploy project/i, + }); + expect(link).toHaveAttribute( + 'href', + '/catalog/default/system/shop/deploy?env=development&intent=deploy', + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); + }); + + it('names the project and env in the setup copy', () => { + renderCallout({ variant: 'setup' }); + expect(screen.getByText('Project not deployed')).toBeInTheDocument(); + expect( + screen.getByText(/shop hasn't been deployed to Development/i), + ).toBeInTheDocument(); + }); + + it('uses promote-specific copy', () => { + renderCallout({ variant: 'promote', envName: 'Staging' }); + expect( + screen.getByText(/isn't deployed to Staging\. Deploy the project there/i), + ).toBeInTheDocument(); + }); + + it('uses recovery copy in the error dialog', () => { + renderCallout({ variant: 'error-dialog' }); + expect( + screen.getByText(/recover automatically on the next reconcile/i), + ).toBeInTheDocument(); + }); + + it('falls back to the env display name in the link when no resource name', () => { + renderCallout({ envResourceName: undefined, envName: 'Development' }); + expect( + screen.getByRole('link', { name: /deploy project/i }), + ).toHaveAttribute( + 'href', + '/catalog/default/system/shop/deploy?env=development&intent=deploy', + ); + }); + + it('disables the deploy action when the component has no project annotation', () => { + renderCallout({}, { annotations: {} }); + const control = screen.getByText(/deploy project/i).closest('a, button'); + expect(control).toHaveClass('Mui-disabled'); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.tsx b/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.tsx new file mode 100644 index 000000000..ad765fc8e --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/components/ProjectNotDeployedCallout.tsx @@ -0,0 +1,102 @@ +import { Box, Button, Typography } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import { Alert, AlertTitle } from '@material-ui/lab'; +import OpenInNewIcon from '@material-ui/icons/OpenInNew'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { CHOREO_ANNOTATIONS } from '@openchoreo/backstage-plugin-common'; + +const useStyles = makeStyles(theme => ({ + root: { + // Let the callout's own copy carry the message; keep the Alert compact. + '& .MuiAlert-message': { width: '100%' }, + }, + body: { marginBottom: theme.spacing(1) }, + action: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(0.5), + }, +})); + +/** + * Where the callout is shown — drives only the body copy. The remediation + * action (open the project's Deploy tab in a new tab) is identical in all + * three, so this is the single place to swap the trigger if we later move to + * an in-place flow. + */ +export type ProjectNotDeployedVariant = 'setup' | 'promote' | 'error-dialog'; + +export interface ProjectNotDeployedCalloutProps { + /** Environment display name shown in the copy (e.g. "Development"). */ + envName: string; + /** Environment K8s resource name used in the deep link (e.g. "development"). */ + envResourceName?: string; + variant: ProjectNotDeployedVariant; +} + +/** + * Warning callout shown when a component targets an environment where its + * project is not deployed. The component's ReleaseBinding applies into the + * project's cell namespace, which only exists once the project is deployed + * there — so deploy/promote is blocked (or has failed) until then. + * + * The action deep-links to the project's own Deploy tab in a new browser tab, + * pre-selected at the blocking environment (`?env=&intent=deploy`), which is + * the canonical place to deploy a project. + */ +export const ProjectNotDeployedCallout = ({ + envName, + envResourceName, + variant, +}: ProjectNotDeployedCalloutProps) => { + const classes = useStyles(); + const { entity } = useEntity(); + + const namespace = entity.metadata.namespace || 'default'; + const projectName = + entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT] ?? ''; + const projectLabel = projectName || 'The project'; + + const params = new URLSearchParams({ + env: (envResourceName ?? envName).toLowerCase(), + intent: 'deploy', + }); + const deployUrl = `/catalog/${namespace}/system/${projectName}/deploy?${params.toString()}`; + + const body = (() => { + switch (variant) { + case 'promote': + return `${projectLabel} isn't deployed to ${envName}. Deploy the project there before promoting this component.`; + case 'error-dialog': + return `${projectLabel} has no deployment in ${envName}, so its namespace doesn't exist on the data plane. Deploy the project to ${envName}, then this component will recover automatically on the next reconcile.`; + case 'setup': + default: + return `${projectLabel} hasn't been deployed to ${envName}. Components can only run in environments where the project is deployed.`; + } + })(); + + return ( + + Project not deployed + + {body} + + + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/components/PromotePrimaryAction.tsx b/plugins/openchoreo/src/components/Environments/components/PromotePrimaryAction.tsx index aabca2b35..ea849275a 100644 --- a/plugins/openchoreo/src/components/Environments/components/PromotePrimaryAction.tsx +++ b/plugins/openchoreo/src/components/Environments/components/PromotePrimaryAction.tsx @@ -23,6 +23,8 @@ export interface PromotePrimaryActionProps { statusReason?: string; promotionTargets?: PromotionTargetInfo[]; isAlreadyPromoted: (targetEnvName: string) => boolean; + /** Reports whether a target env's project is undeployed (blocks promote). */ + isTargetProjectBlocked?: (target: PromotionTargetInfo) => boolean; promotionTracker: ItemActionTracker; onPromote: (targetEnvName: string) => Promise; } @@ -56,6 +58,7 @@ export const PromotePrimaryAction = ({ statusReason, promotionTargets, isAlreadyPromoted, + isTargetProjectBlocked, promotionTracker, onPromote, }: PromotePrimaryActionProps) => { @@ -67,6 +70,7 @@ export const PromotePrimaryAction = ({ statusReason, promotionTargets, isAlreadyPromoted, + isTargetProjectBlocked, promotionTracker, suspendTracker: noopTracker, onPromote, @@ -173,7 +177,8 @@ function PromotePrimaryButton({ action }: { action: PromotionTargetAction }) { const { canPromote, loading, deniedTooltip } = usePromoteToEnvPermission(targetEnvName); const disabled = action.disabled || loading || !canPromote; - const tooltip = !canPromote && !loading ? deniedTooltip : ''; + const tooltip = + action.blockedReason || (!canPromote && !loading ? deniedTooltip : ''); return ( @@ -206,7 +211,8 @@ function PromoteSubMenuItem({ const { canPromote, loading, deniedTooltip } = usePromoteToEnvPermission(targetEnvName); const disabled = action.disabled || loading || !canPromote; - const tooltip = !canPromote && !loading ? deniedTooltip : ''; + const tooltip = + action.blockedReason || (!canPromote && !loading ? deniedTooltip : ''); return ( diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx index 582df7af4..55dbf3474 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.test.tsx @@ -117,6 +117,8 @@ let contextOverride: Partial<{ latestReleaseName: string | null; awaitingNewRelease: boolean; componentError: { reason?: string; message?: string } | null; + environments: Array>; + lowestEnvironment: string; }> = {}; jest.mock('../EnvironmentsContext', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -550,4 +552,83 @@ describe('SetupDetailPane', () => { 'true', ); }); + + describe('project-not-deployed block (S1)', () => { + it('disables Deploy and shows the callout when the project is not deployed in the first env', () => { + // Display name is capitalised while lowestEnvironment is lowercased — + // the block must still match (regression guard for the casing bug). + contextOverride = { + environments: [ + { + name: 'Development', + resourceName: 'development', + projectDeploymentStatus: 'not-deployed', + deployment: {}, + endpoints: [], + }, + ], + lowestEnvironment: 'development', + }; + + renderPane(); + + expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( + 'data-disabled', + 'true', + ); + expect(screen.getByText('Project not deployed')).toBeInTheDocument(); + }); + + it('keeps Deploy enabled and shows an info line when the project deployment is pending', () => { + contextOverride = { + environments: [ + { + name: 'Development', + resourceName: 'development', + projectDeploymentStatus: 'pending', + deployment: {}, + endpoints: [], + }, + ], + lowestEnvironment: 'development', + }; + + renderPane(); + + expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( + 'data-disabled', + 'false', + ); + expect( + screen.queryByText('Project not deployed'), + ).not.toBeInTheDocument(); + expect(screen.getByText(/still in progress/i)).toBeInTheDocument(); + }); + + it('does not block when the project is deployed (ready)', () => { + contextOverride = { + environments: [ + { + name: 'Development', + resourceName: 'development', + projectDeploymentStatus: 'ready', + deployment: {}, + endpoints: [], + }, + ], + lowestEnvironment: 'development', + }; + + renderPane(); + + expect(screen.getByTestId('deploy-release-panel')).toHaveAttribute( + 'data-disabled', + 'false', + ); + expect( + screen.queryByText('Project not deployed'), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/still in progress/i)).not.toBeInTheDocument(); + }); + }); }); diff --git a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx index 22b0a95c6..b6dba754e 100644 --- a/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx +++ b/plugins/openchoreo/src/components/Environments/components/SetupDetailPane.tsx @@ -24,6 +24,11 @@ import { AutoDeployConfirmationDialog } from './AutoDeployConfirmationDialog'; import { DeployReleasePanel } from './DeployReleasePanel'; import { NotificationBanner } from './NotificationBanner'; import { DeploymentFailureBanner } from './DeploymentFailureBanner'; +import { ProjectNotDeployedCallout } from './ProjectNotDeployedCallout'; +import { + isProjectBlocking, + isProjectPending, +} from '../utils/projectDeployment'; import { ReleaseBrowserDialog } from './ReleaseBrowserDialog'; import { ReleaseManifestDialog } from './ReleaseManifestDialog'; import type { ComponentRelease } from '@openchoreo/backstage-plugin-common'; @@ -350,6 +355,20 @@ export const SetupDetailPane = ({ const canCreate = !permissionLoading && canConfigureAndDeploy && readiness.canCreateRelease; + // A component can only deploy into an environment where its project is + // deployed (the project owns the cell namespace). Block the manual Deploy + // for the first env when the project isn't deployed there, and surface the + // fix; a `pending` project deployment is not blocked (the controller + // converges) but is noted. Creating a release is never blocked. + // + // `lowestEnvironment` is `environments[0].name` lowercased, so match + // case-insensitively (the env's display name keeps its original casing). + const firstEnv = environments.find( + e => e.name.toLowerCase() === lowestEnvironment.toLowerCase(), + ); + const projectBlocked = !!firstEnv && isProjectBlocking(firstEnv); + const projectPending = !!firstEnv && isProjectPending(firstEnv); + return ( @@ -501,14 +520,37 @@ export const SetupDetailPane = ({ selectedReleaseName={selectedReleaseName} onSelectedReleaseChange={setSelectedReleaseName} firstEnvironmentName={lowestEnvironment} - disabled={permissionLoading || !canConfigureAndDeploy} - disabledReason={deniedTooltip} + disabled={ + permissionLoading || + !canConfigureAndDeploy || + projectBlocked + } + disabledReason={ + projectBlocked + ? `Project is not deployed to ${lowestEnvironment} yet.` + : deniedTooltip + } onCreateRelease={ isWorkloadEditorSupported ? onConfigureWorkload : undefined } canCreateRelease={canCreate && !readiness.loading} createDisabledReason={createDisabledReason} /> + {projectBlocked && firstEnv && ( + + + + )} + {projectPending && !projectBlocked && ( + + Project deployment to {lowestEnvironment} is still in + progress. + + )} )} diff --git a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts index dc993c770..d03a37e0f 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/useEnvironmentData.ts @@ -25,6 +25,14 @@ export interface Environment { name: string; resourceName?: string; bindingName?: string; + /** + * Whether the owning project is deployed in this environment (its cell + * namespace exists), which a component requires before it can run here. + * `ready` — deployed; `pending` — project binding exists but its namespace + * isn't ready yet; `not-deployed` — no project binding for this env. + * Absent → treat as deployed (backend fail-open). + */ + projectDeploymentStatus?: 'ready' | 'pending' | 'not-deployed'; hasComponentTypeOverrides?: boolean; dataPlaneRef?: string; dataPlaneKind?: 'DataPlane' | 'ClusterDataPlane'; diff --git a/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.test.ts b/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.test.ts index 5b8ff2c79..a6d859ce4 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.test.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.test.ts @@ -67,6 +67,32 @@ describe('usePromotionAction', () => { expect(result.current.primaryPromotion?.target.name).toBe('staging'); }); + it('blocks a target whose project is not deployed there, with a reason', () => { + const { result } = renderHook(() => + usePromotionAction({ + environmentName: 'dev', + deploymentStatus: 'Ready', + promotionTargets: [{ name: 'staging' }, { name: 'prod' }], + isAlreadyPromoted: () => false, + isTargetProjectBlocked: target => target.name === 'staging', + promotionTracker: tracker(), + suspendTracker: tracker(), + onPromote: jest.fn(), + onSuspend: jest.fn(), + onRedeploy: jest.fn(), + }), + ); + const [staging, prod] = result.current.promotionActions; + expect(staging.disabled).toBe(true); + expect(staging.blockedReason).toBe( + 'Project is not deployed to staging yet.', + ); + expect(prod.disabled).toBe(false); + expect(prod.blockedReason).toBeUndefined(); + // The non-blocked target is preferred as the primary. + expect(result.current.primaryPromotion?.target.name).toBe('prod'); + }); + it('marks already-promoted targets as disabled and prefers a non-promoted primary', () => { const { result } = renderHook(() => usePromotionAction({ diff --git a/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.ts b/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.ts index c7bc8ccd6..26336cb6a 100644 --- a/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.ts +++ b/plugins/openchoreo/src/components/Environments/hooks/usePromotionAction.ts @@ -11,11 +11,17 @@ export interface PromotionTargetAction { label: string; /** * True when the action is disabled for non-permission reasons - * (already promoted, in-flight). Permission-driven disabling is layered - * on top by the consumer via `usePromoteToEnvPermission(target)` — hooks - * cannot be called in a loop here. + * (already promoted, in-flight, target project not deployed). Permission- + * driven disabling is layered on top by the consumer via + * `usePromoteToEnvPermission(target)` — hooks cannot be called in a loop + * here. */ disabled: boolean; + /** + * Set when the target env's project is not deployed there — the reason the + * action is blocked, surfaced as a tooltip by consumers. + */ + blockedReason?: string; isAlreadyPromoted: boolean; isPromoting: boolean; onClick: () => void; @@ -44,6 +50,12 @@ export interface UsePromotionActionInput { statusReason?: string; promotionTargets?: PromotionTargetInfo[]; isAlreadyPromoted: (targetEnvName: string) => boolean; + /** + * Reports whether a target env has its project undeployed (cell namespace + * missing), which blocks promotion into it. Optional — omitted by consumers + * where the project prerequisite does not apply (e.g. resource envs). + */ + isTargetProjectBlocked?: (target: PromotionTargetInfo) => boolean; promotionTracker: ItemActionTracker; suspendTracker: ItemActionTracker; onPromote: (targetEnvName: string) => void | Promise; @@ -76,6 +88,7 @@ export function usePromotionAction({ statusReason, promotionTargets, isAlreadyPromoted, + isTargetProjectBlocked, promotionTracker, suspendTracker, onPromote, @@ -108,6 +121,7 @@ export function usePromotionAction({ const targetKey = target.resourceName ?? target.name; const promoted = isAlreadyPromoted(target.name); const promoting = promotionTracker.isActive(targetKey); + const projectBlocked = isTargetProjectBlocked?.(target) ?? false; let label: string; if (promoted) { label = `Promoted to ${target.name}`; @@ -119,7 +133,10 @@ export function usePromotionAction({ return { target, label, - disabled: promoting || promoted, + disabled: promoting || promoted || projectBlocked, + blockedReason: projectBlocked + ? `Project is not deployed to ${target.name} yet.` + : undefined, isAlreadyPromoted: promoted, isPromoting: promoting, onClick: () => onPromote(targetKey), diff --git a/plugins/openchoreo/src/components/Environments/utils/projectDeployment.test.ts b/plugins/openchoreo/src/components/Environments/utils/projectDeployment.test.ts new file mode 100644 index 000000000..10abe31d3 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/utils/projectDeployment.test.ts @@ -0,0 +1,137 @@ +import type { Environment } from '../hooks/useEnvironmentData'; +import { + isProjectBlocking, + isProjectPending, + attributeToProjectNotDeployed, + makeIsTargetProjectBlocked, +} from './projectDeployment'; + +const makeEnv = (over: Partial = {}): Environment => ({ + name: 'Development', + resourceName: 'development', + endpoints: [], + deployment: {}, + ...over, +}); + +describe('isProjectBlocking / isProjectPending', () => { + it('blocks only on not-deployed', () => { + expect( + isProjectBlocking(makeEnv({ projectDeploymentStatus: 'not-deployed' })), + ).toBe(true); + expect( + isProjectBlocking(makeEnv({ projectDeploymentStatus: 'pending' })), + ).toBe(false); + expect( + isProjectBlocking(makeEnv({ projectDeploymentStatus: 'ready' })), + ).toBe(false); + // absent → fail-open (not blocking) + expect(isProjectBlocking(makeEnv())).toBe(false); + }); + + it('flags pending only on pending', () => { + expect( + isProjectPending(makeEnv({ projectDeploymentStatus: 'pending' })), + ).toBe(true); + expect( + isProjectPending(makeEnv({ projectDeploymentStatus: 'not-deployed' })), + ).toBe(false); + expect(isProjectPending(makeEnv())).toBe(false); + }); +}); + +describe('attributeToProjectNotDeployed', () => { + it('true on the first-class ProjectNotDeployed reason', () => { + expect( + attributeToProjectNotDeployed( + makeEnv({ + projectDeploymentStatus: 'not-deployed', + deployment: { status: 'Failed', statusReason: 'ProjectNotDeployed' }, + }), + ), + ).toBe(true); + }); + + it('true on the namespace-not-found heuristic when the project is not deployed', () => { + expect( + attributeToProjectNotDeployed( + makeEnv({ + projectDeploymentStatus: 'not-deployed', + deployment: { + status: 'Failed', + statusReason: 'ResourceApplyFailed', + statusMessage: + 'Failed to apply resources to target plane: failed to apply resource deployment-x: namespaces "dp-default-test-project--development-0569e83c" not found', + }, + }), + ), + ).toBe(true); + }); + + it('false when the same apply error occurs but the project IS deployed (genuine failure)', () => { + expect( + attributeToProjectNotDeployed( + makeEnv({ + projectDeploymentStatus: 'ready', + deployment: { + status: 'Failed', + statusReason: 'ResourceApplyFailed', + statusMessage: 'namespaces "dp-x" not found', + }, + }), + ), + ).toBe(false); + }); + + it('false for unrelated failures and non-failed states', () => { + expect( + attributeToProjectNotDeployed( + makeEnv({ + projectDeploymentStatus: 'not-deployed', + deployment: { + status: 'Failed', + statusReason: 'RenderingFailed', + statusMessage: 'CEL error', + }, + }), + ), + ).toBe(false); + expect( + attributeToProjectNotDeployed( + makeEnv({ + projectDeploymentStatus: 'not-deployed', + deployment: { status: 'Ready' }, + }), + ), + ).toBe(false); + }); +}); + +describe('makeIsTargetProjectBlocked', () => { + const envs = [ + makeEnv({ + name: 'Development', + resourceName: 'development', + projectDeploymentStatus: 'ready', + }), + makeEnv({ + name: 'Staging', + resourceName: 'staging', + projectDeploymentStatus: 'not-deployed', + }), + ]; + const isBlocked = makeIsTargetProjectBlocked(envs); + + it('resolves a target by display name', () => { + expect(isBlocked({ name: 'Staging' })).toBe(true); + expect(isBlocked({ name: 'Development' })).toBe(false); + }); + + it('resolves a target by resource name', () => { + expect(isBlocked({ name: 'x', resourceName: 'staging' })).toBe(true); + }); + + it('treats an unknown target as not blocked (fail-open)', () => { + expect(isBlocked({ name: 'production' })).toBe(false); + }); +}); diff --git a/plugins/openchoreo/src/components/Environments/utils/projectDeployment.ts b/plugins/openchoreo/src/components/Environments/utils/projectDeployment.ts new file mode 100644 index 000000000..1f0a6a649 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/utils/projectDeployment.ts @@ -0,0 +1,75 @@ +import type { Environment } from '../hooks/useEnvironmentData'; + +/** + * Helpers for the "project must be deployed before a component can run in an + * environment" rule. A component's ReleaseBinding applies its manifests into + * the project's cell namespace, which is created by the project's + * ProjectReleaseBinding — so a component deploy/promote into an environment + * where the project is not deployed fails with a namespace-not-found error. + * + * The backend exposes `Environment.projectDeploymentStatus`; an absent value + * means the check could not run and is treated as deployed (fail-open — never + * block on missing data). + */ + +/** The project has no deployment in this env — deploy/promote here is blocked. */ +export function isProjectBlocking( + env: Pick, +): boolean { + return env.projectDeploymentStatus === 'not-deployed'; +} + +/** + * Builds a predicate that reports whether a promotion target env has its + * project undeployed (so promoting into it is blocked). Resolves the target + * against the loaded environments by display or resource name; an unknown + * target is treated as not blocked (fail-open). + */ +export function makeIsTargetProjectBlocked(environments: Environment[]) { + return (target: { name: string; resourceName?: string }): boolean => { + const env = environments.find( + e => + e.name === target.name || + (!!target.resourceName && e.resourceName === target.resourceName), + ); + return env ? isProjectBlocking(env) : false; + }; +} + +/** + * The project's binding exists but its namespace isn't ready yet. Not blocked + * (the controller converges) — surfaced as an informational note. + */ +export function isProjectPending( + env: Pick, +): boolean { + return env.projectDeploymentStatus === 'pending'; +} + +/** Matches the controller's raw "namespace missing" apply error. */ +const NAMESPACE_NOT_FOUND = /namespaces "[^"]*" not found/i; + +/** + * Whether a component binding's failure is attributable to the project not + * being deployed in that environment. True when either: + * - the controller reports the first-class `ProjectNotDeployed` reason + * (future openchoreo-side enhancement), or + * - (today's heuristic) the reason is `ResourceApplyFailed` and the message is + * the namespace-not-found apply error, unless the project is explicitly + * known to be deployed (`projectDeploymentStatus === 'ready'`). The guard + * only excludes the `ready` case, so `not-deployed`, `pending`, and an + * absent/unknown status are all treated as eligible for attribution + * (fail-open) — an absent status still gets the plain-language explanation + * rather than the raw error. It only avoids mislabeling a genuine apply + * failure when the project is confirmed deployed. + */ +export function attributeToProjectNotDeployed(env: Environment): boolean { + const { status, statusReason, statusMessage } = env.deployment; + if (status !== 'Failed') return false; + if (statusReason === 'ProjectNotDeployed') return true; + const looksLikeMissingNamespace = + statusReason === 'ResourceApplyFailed' && + !!statusMessage && + NAMESPACE_NOT_FOUND.test(statusMessage); + return looksLikeMissingNamespace && env.projectDeploymentStatus !== 'ready'; +} diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx index 5e842723b..a78ea9c34 100644 --- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx +++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.test.tsx @@ -22,9 +22,11 @@ jest.mock('@backstage/plugin-catalog-react', () => ({ })); const mockNavigate = jest.fn(); +let mockSearchParams = new URLSearchParams(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, + useSearchParams: () => [mockSearchParams, jest.fn()], })); jest.mock('@backstage/core-components', () => ({ @@ -113,6 +115,7 @@ jest.mock('./ProjectSetupDetailPane', () => ({ beforeEach(() => { jest.clearAllMocks(); + mockSearchParams = new URLSearchParams(); mockClient.updateProjectReleaseBinding.mockResolvedValue({ ok: true }); }); @@ -129,6 +132,38 @@ describe('ProjectEnvironmentsList', () => { expect(screen.getByTestId('detail').textContent).toBe('none'); }); + it('pre-selects the arrival environment from ?env= and shows the hint on intent=deploy', async () => { + mockSearchParams = new URLSearchParams({ + env: 'staging', + intent: 'deploy', + }); + mockClient.fetchProjectEnvironmentInfo.mockResolvedValue([ + { name: 'dev', resourceName: 'dev' }, + { name: 'Staging', resourceName: 'staging' }, + ]); + + render(); + + // Selection seeded from the deep link (matched by resource name). + expect((await screen.findByTestId('detail')).textContent).toBe('Staging'); + // One-time arrival hint. + expect( + screen.getByText(/return to your component tab to continue/i), + ).toBeInTheDocument(); + }); + + it('shows no arrival hint without intent=deploy', async () => { + mockSearchParams = new URLSearchParams({ env: 'dev' }); + mockClient.fetchProjectEnvironmentInfo.mockResolvedValue([{ name: 'dev' }]); + + render(); + + await screen.findByTestId('canvas'); + expect( + screen.queryByText(/return to your component tab to continue/i), + ).not.toBeInTheDocument(); + }); + it('selects an environment and shows it in the detail panel', async () => { mockClient.fetchProjectEnvironmentInfo.mockResolvedValue([{ name: 'dev' }]); render(); diff --git a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx index 6e5252224..a2b2e6b6c 100644 --- a/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx +++ b/plugins/openchoreo/src/components/ProjectEnvironments/ProjectEnvironmentsList.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box } from '@material-ui/core'; -import { useNavigate } from 'react-router-dom'; +import { Alert } from '@material-ui/lab'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Progress, EmptyState } from '@backstage/core-components'; import { useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; @@ -50,6 +51,15 @@ export const ProjectEnvironmentsList = () => { const [selectedSetup, setSelectedSetup] = useState(false); const cancelledRef = useRef(false); + // Arrival from a component's "Deploy project" hand-off: + // `?env=&intent=deploy` pre-selects that environment and shows a + // one-time hint. Seeding runs once, after envs load. + const [searchParams] = useSearchParams(); + const arrivalEnv = searchParams.get('env'); + const arrivalIntent = searchParams.get('intent'); + const [arrivalHintDismissed, setArrivalHintDismissed] = useState(false); + const arrivalSeededRef = useRef(false); + // Selecting an env clears the Setup selection and vice versa — the // right pane shows at most one of them. const setSelectedEnvName = useCallback((name: string | null) => { @@ -113,6 +123,21 @@ export const ProjectEnvironmentsList = () => { } }, [envs, selectedEnvName]); + // Pre-select the arrival environment once, after envs load. Match on the + // resource name (what the deep link carries) with a display-name fallback. + useEffect(() => { + if (arrivalSeededRef.current || !arrivalEnv || envs.length === 0) return; + const match = envs.find( + e => + e.resourceName === arrivalEnv || + e.name.toLowerCase() === arrivalEnv.toLowerCase(), + ); + if (match) { + setSelectedEnvName(match.name); + arrivalSeededRef.current = true; + } + }, [arrivalEnv, envs, setSelectedEnvName]); + // Background poll while any binding is mid-rollout. Pin advances kick the // controller into a Progressing state that flips back to Ready once the // underlying RenderedRelease is reconciled. @@ -194,6 +219,16 @@ export const ProjectEnvironmentsList = () => { return ( + {arrivalIntent === 'deploy' && !arrivalHintDismissed && ( + setArrivalHintDismissed(true)} + style={{ marginBottom: 8 }} + > + Deploy the project here, then return to your component tab to + continue. + + )}