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/pages/installed-operators-page.ts b/frontend/e2e/pages/installed-operators-page.ts new file mode 100644 index 00000000000..5eeb6ec9295 --- /dev/null +++ b/frontend/e2e/pages/installed-operators-page.ts @@ -0,0 +1,34 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class InstalledOperatorsPage extends BasePage { + async navigateTo(namespace?: string): Promise { + const path = namespace + ? `/k8s/ns/${namespace}/operators.coreos.com~v1alpha1~ClusterServiceVersion` + : `/k8s/all-namespaces/operators.coreos.com~v1alpha1~ClusterServiceVersion`; + await this.goTo(path); + } + + getOperatorRow(displayName: string): Locator { + return this.page + .locator('tr') + .filter({ has: this.page.getByTestId(`operator-row-${displayName}`) }); + } + + getCompatibleIndicator(displayName: string): Locator { + return this.getOperatorRow(displayName).getByTestId('cluster-compatibility-compatible'); + } + + getIncompatibleIndicator(displayName: string): Locator { + return this.getOperatorRow(displayName).getByTestId('cluster-compatibility-incompatible'); + } + + getSupportPhaseBadge(displayName: string): Locator { + return this.getOperatorRow(displayName).getByTestId('support-phase-badge'); + } + + getSelfSupportBadge(displayName: string): Locator { + return this.getOperatorRow(displayName).getByTestId('support-phase-self-support'); + } +} 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..1a859d7c975 --- /dev/null +++ b/frontend/e2e/tests/olm/operator-lifecycle-metadata.spec.ts @@ -0,0 +1,217 @@ +import { test, expect } from '../../fixtures'; +import type { LifecycleData } from '../../mocks/operator-lifecycle'; +import { + makeLifecycleActiveAndCompatible, + makeLifecycleSelfSupport, + makeLifecycleIncompatible, +} from '../../mocks/operator-lifecycle'; +import { InstalledOperatorsPage } from '../../pages/installed-operators-page'; + +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 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 operatorVersion: string; + let operatorDisplayName: string; + + test.beforeAll(async ({ k8sClient }) => { + test.setTimeout(INSTALL_TIMEOUT + 60_000); + + 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) { + try { + const sub = (await k8sClient.getCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'subscriptions', + PACKAGE_NAME, + )) as { status?: { installedCSV?: string } }; + + const csvName = sub?.status?.installedCSV; + if (csvName) { + const csvResource = (await k8sClient.getCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + csvName, + )) as CSVResource; + + if (csvResource?.status?.phase === 'Succeeded') { + csv = csvResource; + break; + } + } + } catch { + // subscription or CSV not ready yet + } + 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 ({ k8sClient }) => { + const csvs = (await k8sClient.listCustomResources( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + )) as CSVResource[]; + + await Promise.allSettled([ + k8sClient.deleteCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'subscriptions', + PACKAGE_NAME, + ), + ...csvs + .filter((c) => c.metadata?.name?.startsWith(CSV_NAME_PREFIX)) + .map((c) => + k8sClient.deleteCustomResource( + OLM_GROUP, + OLM_VERSION, + OPERATOR_NAMESPACE, + 'clusterserviceversions', + c.metadata!.name!, + ), + ), + ]); + }); + + test('displays lifecycle metadata columns for installed operator', async ({ page }) => { + let activeLifecycleData: LifecycleData | null = null; + + 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(); + } + }); + + const installedOperators = new InstalledOperatorsPage(page); + await installedOperators.navigateTo(OPERATOR_NAMESPACE); + + const serverFlags = await page.evaluate(() => window.SERVER_FLAGS); + test.skip( + !serverFlags.olmLifecycleMetadata, + 'Lifecycle metadata columns require the OLMLifecycleAndCompatibility feature gate', + ); + const releaseVersion = serverFlags.releaseVersion ?? ''; + const versionMatch = releaseVersion.match(/^(\d+\.\d+)/); + const clusterMinorVersion = versionMatch ? versionMatch[1] : '4.18'; + + await test.step('Active support phase and compatible cluster', async () => { + activeLifecycleData = makeLifecycleActiveAndCompatible( + PACKAGE_NAME, + operatorVersion, + clusterMinorVersion, + ); + + await page.reload(); + await expect( + installedOperators.getOperatorRow(operatorDisplayName), + ).toBeVisible({ timeout: 30_000 }); + + await expect( + installedOperators.getCompatibleIndicator(operatorDisplayName), + ).toContainText('Compatible', { timeout: 30_000 }); + + await expect( + installedOperators.getSupportPhaseBadge(operatorDisplayName), + ).toContainText('Maintenance support'); + }); + + await test.step('Unsupported when all phases expired', async () => { + activeLifecycleData = makeLifecycleSelfSupport( + PACKAGE_NAME, + operatorVersion, + clusterMinorVersion, + ); + + await page.reload(); + await expect( + installedOperators.getOperatorRow(operatorDisplayName), + ).toBeVisible({ timeout: 30_000 }); + + await expect( + installedOperators.getSelfSupportBadge(operatorDisplayName), + ).toContainText('Unsupported', { 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( + installedOperators.getOperatorRow(operatorDisplayName), + ).toBeVisible({ timeout: 30_000 }); + + await expect( + installedOperators.getIncompatibleIndicator(operatorDisplayName), + ).toContainText('Incompatible', { timeout: 30_000 }); + }); + }); +}); diff --git a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx b/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx index d7a5de3124e..0fa96226a07 100644 --- a/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx +++ b/frontend/packages/operator-lifecycle-manager/src/components/clusterserviceversion.tsx @@ -390,6 +390,7 @@ export const ClusterServiceVersionTableRow = withFallback &2 + exit 1 +fi +case "$ARTIFACT_DIR" in + /) echo "Error: ARTIFACT_DIR must not be '/'" >&2; exit 1 ;; + /*) ;; # absolute path, OK + *) echo "Error: ARTIFACT_DIR must be an absolute path, got: $ARTIFACT_DIR" >&2; exit 1 ;; +esac + +export ARTIFACT_DIR INSTALLER_DIR +mkdir -p "${ARTIFACT_DIR}" + +# don't log kubeadmin-password +set +x +export BRIDGE_KUBEADMIN_PASSWORD="$(cat "${KUBEADMIN_PASSWORD_FILE:-${INSTALLER_DIR}/auth/kubeadmin-password}")" +set -x +export BRIDGE_BASE_ADDRESS="$(oc get consoles.config.openshift.io cluster -o jsonpath='{.status.consoleURL}')" + +./contrib/create-user.sh + +pushd frontend + +./integration-tests/test-playwright-e2e.sh -- --project=olm e2e/tests/olm/operator-lifecycle-metadata.spec.ts "$@" + +popd