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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/component-deploy-project-prereq.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<string, ModelsEnvironment>();
Expand Down Expand Up @@ -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<string, NewProjectReleaseBinding>();
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 (
Expand Down Expand Up @@ -427,6 +472,7 @@ export class EnvironmentInfoService implements EnvironmentService {
envData,
binding,
promotionTargets,
projectDeploymentStatusFor(envName),
);

orderedEnvironments.push(transformedEnv);
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<typeof createOpenChoreoApiClient>,
namespaceName: string,
projectName: string,
): Promise<NewProjectReleaseBinding[] | null> {
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<string, string>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions plugins/openchoreo-backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading