diff --git a/frontend/e2e/mocks/operator-lifecycle.ts b/frontend/e2e/mocks/operator-lifecycle.ts new file mode 100644 index 00000000000..e24696e64fd --- /dev/null +++ b/frontend/e2e/mocks/operator-lifecycle.ts @@ -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(), + }, + ], +}); diff --git a/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts new file mode 100644 index 00000000000..1fa87e56f99 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts @@ -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 }); + }); + }); +});