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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions frontend/e2e/mocks/operator-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export type LifecycleData = {
package: string;
schema: string;
versions?: {
name: string;
platformCompatibility?: { name: string; versions: string[] }[];
phases?: { name: string; startDate: string; endDate: string }[];
}[];
};

const LIFECYCLE_SCHEMA = 'io.openshift.operators.lifecycles.v1alpha1';

const toDateStr = (d: Date): string => d.toISOString().slice(0, 10);

const activePhases = (): { name: string; startDate: string; endDate: string }[] => {
const now = new Date();
const maintenanceStart = new Date(now.getFullYear() - 1, 0, 1);
const maintenanceEnd = new Date(now.getFullYear() + 1, 5, 30);
const extendedStart = new Date(maintenanceEnd);
extendedStart.setDate(extendedStart.getDate() + 1);
const extendedEnd = new Date(now.getFullYear() + 3, 11, 31);
return [
{ name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) },
{ name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) },
];
};

const expiredPhases = (): { name: string; startDate: string; endDate: string }[] => {
const now = new Date();
const maintenanceStart = new Date(now.getFullYear() - 3, 0, 1);
const maintenanceEnd = new Date(now.getFullYear() - 2, 5, 30);
const extendedStart = new Date(maintenanceEnd);
extendedStart.setDate(extendedStart.getDate() + 1);
const extendedEnd = new Date(now.getFullYear() - 1, 11, 31);
return [
{ name: 'Maintenance support', startDate: toDateStr(maintenanceStart), endDate: toDateStr(maintenanceEnd) },
{ name: 'Extended life cycle support', startDate: toDateStr(extendedStart), endDate: toDateStr(extendedEnd) },
];
};

export const makeLifecycleActiveAndCompatible = (
packageName: string,
version: string,
clusterVersion: string,
): LifecycleData => ({
package: packageName,
schema: LIFECYCLE_SCHEMA,
versions: [
{
name: version,
platformCompatibility: [{ name: 'openshift', versions: [clusterVersion] }],
phases: activePhases(),
},
],
});

export const makeLifecycleSelfSupport = (
packageName: string,
version: string,
clusterVersion: string,
): LifecycleData => ({
package: packageName,
schema: LIFECYCLE_SCHEMA,
versions: [
{
name: version,
platformCompatibility: [{ name: 'openshift', versions: [clusterVersion] }],
phases: expiredPhases(),
},
],
});

export const makeLifecycleIncompatible = (
packageName: string,
version: string,
): LifecycleData => ({
package: packageName,
schema: LIFECYCLE_SCHEMA,
versions: [
{
name: version,
platformCompatibility: [{ name: 'openshift', versions: ['4.99'] }],
phases: activePhases(),
},
],
});
213 changes: 213 additions & 0 deletions frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { test, expect } from '../../fixtures';
import type KubernetesClient from '../../clients/kubernetes-client';
import type { LifecycleData } from '../../mocks/operator-lifecycle';
import {
makeLifecycleActiveAndCompatible,
makeLifecycleSelfSupport,
makeLifecycleIncompatible,
} from '../../mocks/operator-lifecycle';

const OLM_GROUP = 'operators.coreos.com';
const OLM_VERSION = 'v1alpha1';
const OPERATOR_NAMESPACE = 'openshift-operators';
const PACKAGE_NAME = 'web-terminal';
const CSV_NAME_PREFIX = `${PACKAGE_NAME}.v`;
const CATALOG_SOURCE = 'redhat-operators';
const CATALOG_NAMESPACE = 'openshift-marketplace';
const LIFECYCLE_URL_PATTERN = '**/api/olm/lifecycle/**';
const INSTALLED_OPERATORS_URL = `/k8s/ns/${OPERATOR_NAMESPACE}/${OLM_GROUP}~${OLM_VERSION}~ClusterServiceVersion`;

const INSTALL_TIMEOUT = 300_000;
const POLL_INTERVAL = 10_000;

type CSVResource = {
metadata?: { name?: string };
spec?: { version?: string; displayName?: string };
status?: { phase?: string };
};

const webTerminalSubscription = {
apiVersion: `${OLM_GROUP}/${OLM_VERSION}`,
kind: 'Subscription',
metadata: {
name: PACKAGE_NAME,
namespace: OPERATOR_NAMESPACE,
},
spec: {
channel: 'fast',
name: PACKAGE_NAME,
source: CATALOG_SOURCE,
sourceNamespace: CATALOG_NAMESPACE,
installPlanApproval: 'Automatic',
},
};

test.describe('Operator lifecycle metadata', { tag: ['@admin'] }, () => {
let k8sClient: KubernetesClient;
let operatorVersion: string;
let operatorDisplayName: string;

test.beforeAll(async ({ k8sClient: client }) => {
test.setTimeout(INSTALL_TIMEOUT + 60_000);
k8sClient = client;

try {
await k8sClient.createCustomResource(
OLM_GROUP,
OLM_VERSION,
OPERATOR_NAMESPACE,
'subscriptions',
webTerminalSubscription,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (!msg.includes('409') && !msg.includes('already exists')) {
throw err;
}
}

const deadline = Date.now() + INSTALL_TIMEOUT;
let csv: CSVResource | undefined;
while (Date.now() < deadline) {
const csvs = (await k8sClient.listCustomResources(
OLM_GROUP,
OLM_VERSION,
OPERATOR_NAMESPACE,
'clusterserviceversions',
)) as CSVResource[];

csv = csvs.find(
(c) =>
c.metadata?.name?.startsWith(CSV_NAME_PREFIX) &&
c.status?.phase === 'Succeeded',
);
if (csv) break;
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
}

if (!csv) {
throw new Error(
`Timed out waiting for ${PACKAGE_NAME} CSV to reach Succeeded phase`,
);
}

operatorVersion = csv.spec?.version ?? '';
operatorDisplayName = csv.spec?.displayName ?? PACKAGE_NAME;
});

test.afterAll(async () => {
if (!k8sClient) return;

try {
await k8sClient.deleteCustomResource(
OLM_GROUP,
OLM_VERSION,
OPERATOR_NAMESPACE,
'subscriptions',
PACKAGE_NAME,
);
} catch {
// Ignore cleanup errors
}

try {
const csvs = (await k8sClient.listCustomResources(
OLM_GROUP,
OLM_VERSION,
OPERATOR_NAMESPACE,
'clusterserviceversions',
)) as CSVResource[];
for (const csv of csvs) {
if (csv.metadata?.name?.startsWith(CSV_NAME_PREFIX)) {
await k8sClient.deleteCustomResource(
OLM_GROUP,
OLM_VERSION,
OPERATOR_NAMESPACE,
'clusterserviceversions',
csv.metadata.name,
);
}
}
} catch {
// Ignore cleanup errors
}
});

test('displays lifecycle metadata columns for installed operator', async ({ page }) => {
let activeLifecycleData: LifecycleData | null = null;

// Register the route before navigation so the first page load is intercepted.
// The handler reads from a mutable reference, updated between steps.
await page.route(LIFECYCLE_URL_PATTERN, async (route) => {
if (activeLifecycleData) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(activeLifecycleData),
});
} else {
await route.abort();
}
});

await page.goto(INSTALLED_OPERATORS_URL);
const serverFlags = await page.evaluate(
() => (window as any).SERVER_FLAGS ?? {},
);
test.skip(!serverFlags.techPreview, 'Lifecycle metadata columns require tech preview');
const releaseVersion: string = serverFlags.releaseVersion ?? '';
const versionMatch = releaseVersion.match(/^(\d+\.\d+)/);
const clusterMinorVersion = versionMatch ? versionMatch[1] : '4.18';

const operatorRow = page
.locator('tr')
.filter({ has: page.locator(`[data-test-operator-row="${operatorDisplayName}"]`) });

await test.step('Active support phase and compatible cluster', async () => {
activeLifecycleData = makeLifecycleActiveAndCompatible(
PACKAGE_NAME,
operatorVersion,
clusterMinorVersion,
);

await page.reload();
await expect(operatorRow).toBeVisible({ timeout: 30_000 });

// The lifecycle feature flag loads asynchronously via a lazy code-ref,
// so the columns may take ~20s to appear after the table renders.
await expect(
operatorRow.locator('[data-test="cluster-compatibility-compatible"]'),
).toContainText('Compatible', { timeout: 30_000 });

await expect(
operatorRow.locator('[data-test="support-phase-badge"]'),
).toContainText('Maintenance support');
});

await test.step('Self-support when all phases expired', async () => {
activeLifecycleData = makeLifecycleSelfSupport(
PACKAGE_NAME,
operatorVersion,
clusterMinorVersion,
);

await page.reload();
await expect(operatorRow).toBeVisible({ timeout: 30_000 });

await expect(
operatorRow.locator('[data-test="support-phase-self-support"]'),
).toContainText('Self-support', { timeout: 30_000 });
});

await test.step('Incompatible when cluster version not in compatibility list', async () => {
activeLifecycleData = makeLifecycleIncompatible(PACKAGE_NAME, operatorVersion);

await page.reload();
await expect(operatorRow).toBeVisible({ timeout: 30_000 });

await expect(
operatorRow.locator('[data-test="cluster-compatibility-incompatible"]'),
).toContainText('Incompatible', { timeout: 30_000 });
});
});
});