Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RHOAIENG-16055: new(tests): test to start a Workbench, by creating the Notebook CR directly #94

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dependencies = [
"pytest-testconfig>=0.2.0",
"pytest-jira>=0.3.21",
"pygithub>=2.5.0",
"allure-pytest>=2.13.5",
]

[project.urls]
Expand Down
Empty file added tests/workbenches/__init__.py
Empty file.
309 changes: 309 additions & 0 deletions tests/workbenches/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
from __future__ import annotations
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the majority of these classes could be avoided if you instead used the wrapper library https://github.com/RedHatQE/openshift-python-wrapper


import logging
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use simple_logger

import time
import traceback
from typing import Callable, Generator

import kubernetes.dynamic
from kubernetes.dynamic import DynamicClient

import ocp_resources.pod
import ocp_resources.resource

import pytest


@pytest.fixture(scope="function")
def function_resource_manager(admin_client: DynamicClient) -> Generator[KubeResourceManager, None, None]:
resource_manager = KubeResourceManager(admin_client)
yield resource_manager
resource_manager.destroy()


class KubeResourceManager:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use ocpenshift python wrapper to CRUD resources

def __init__(self, privileged_client: DynamicClient):
self.privileged_client = privileged_client
self.resources: list[ocp_resources.resource.Resource] = []

def createResourceWithoutWait(self, client: DynamicClient, resource: ocp_resources.resource.Resource):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use python naming conventions

resource.client = self.privileged_client
resource.create()
self.resources.append(resource)

def createResourceWithWait(self, client: DynamicClient, resource: ocp_resources.resource.Resource):
resource.client = self.privileged_client
resource.create()
self.resources.append(resource)

def destroy(self):
for resource in self.resources:
# self.privileged_client.delete(resource)
resource.delete()


class OdhAnnotationsLabels:
OPENSHIFT_DOMAIN = "openshift.io/"
ODH_DOMAIN = "opendatahub.io/"
Comment on lines +46 to +47
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used in other places as well, please place under https://github.com/opendatahub-io/opendatahub-tests/blob/main/utilities/constants.py and re-use


LABEL_DASHBOARD = ODH_DOMAIN + "dashboard"
LABEL_ODH_MANAGED = ODH_DOMAIN + "odh-managed"
LABEL_SIDECAR_ISTIO_INJECT = "sidecar.istio.io/inject"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto


ANNO_SERVICE_MESH = ODH_DOMAIN + "service-mesh"
ANNO_MODEL_MESH = "modelmesh-enabled"
ANNO_NTB_INJECT_OAUTH = "notebooks." + ODH_DOMAIN + "inject-oauth"
APP_LABEL_KEY = "app"
APP_LABEL_VALUE = "odh-e2e"


def getOdhOrRhoai(name, odh_value, rhoai_value):
return rhoai_value
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use py_config["distrobution"] where needed



class OdhConstants:
# private static final Logger LOGGER = LoggerFactory.getLogger(OdhConstants.class);
# private static final Map<String, String> VALUES = new HashMap<>();
Comment on lines +65 to +66
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leftovers?

# // ODH
ODH_CONTROLLERS_NAMESPACE: str = "opendatahub"
ODH_DASHBOARD_ROUTE_NAME: str = "odh-dashboard"
# private static final String ODH_DASHBOARD_CONTROLLER = "odh-dashboard";
#
# private static final String ODH_BUNDLE_OPERATOR_NAME = "opendatahub-operator-system";
# private static final String ODH_MONITORING_NAMESPACE = "odh-monitoring";
#
#
# // ODH OLM
# private static final String ODH_OLM_OPERATOR_NAME = "opendatahub-operator";
# private static final String ODH_OLM_OPERATOR_NAMESPACE = "openshift-operators";
# private static final String ODH_OLM_OPERATOR_DEPLOYMENT_NAME = "opendatahub-operator-controller-manager";
# private static final String ODH_OLM_SOURCE_NAME = "community-operators";
# private static final String ODH_OLM_APP_BUNDLE_PREFIX = "opendatahub-operator";
# private static final String ODH_OLM_OPERATOR_CHANNEL = "fast";
# private static final String ODH_DSCI_NAME = "default";
# // TODO - should be changed after 2.5 release
# private static final String ODH_OLM_OPERATOR_VERSION = "v2.8.0";
# private static final String ODH_OLM_UPGRADE_STARTING_OPERATOR_VERSION = "v2.8.0";
#
# // RHOAI
RHOAI_CONTROLLERS_NAMESPACE: str = "redhat-ods-applications"
RHOAI_DASHBOARD_ROUTE_NAME: str = "rhods-dashboard"
# private static final String RHOAI_DASHBOARD_CONTROLLER = "rhods-dashboard";
# private static final String RHOAI_NOTEBOOKS_NAMESPACE = "rhods-notebooks";
# private static final String RHOAI_DSCI_NAME = "default-dsci";
# // RHOAI OLM
# private static final String RHOAI_OLM_OPERATOR_NAME = "rhods-operator";
# private static final String RHOAI_OLM_OPERATOR_NAMESPACE = "redhat-ods-operator";
# private static final String RHOAI_OLM_OPERATOR_DEPLOYMENT_NAME = "rhods-operator";
# private static final String RHOAI_OLM_SOURCE_NAME = "redhat-operators";
# private static final String RHOAI_OLM_APP_BUNDLE_PREFIX = "rhods-operator";
# private static final String RHOAI_OLM_OPERATOR_CHANNEL = "fast";
# private static final String RHOAI_OLM_OPERATOR_VERSION = "2.7.0";
# private static final String RHOAI_OLM_UPGRADE_STARTING_OPERATOR_VERSION = "2.6.0";
# private static final String RHOAI_MONITORING_NAMESPACE = "redhat-ods-monitoring";
#
# // Public part
# public static final String DSC_CREATION_SUCCESSFUL_EVENT_NAME = "DataScienceClusterCreationSuccessful";
#
# public static final String CODEFLARE_DEPLOYMENT_NAME = "codeflare-operator-manager";
# public static final String DS_PIPELINES_OPERATOR = "data-science-pipelines-operator-controller-manager";
# public static final String ETCD = "etcd";
# public static final String KSERVE_OPERATOR = "kserve-controller-manager";
# public static final String KUBERAY_OPERATOR = "kuberay-operator";
# public static final String MODELMESH_OPERATOR = "modelmesh-controller";
# public static final String NOTEBOOK_OPERATOR = "notebook-controller-deployment";
# public static final String ODH_MODEL_OPERATOR = "odh-model-controller";
# public static final String ODH_NOTEBOOK_OPERATOR = "odh-notebook-controller-manager";
# public static final String KUEUE_OPERATOR = "kueue-controller-manager";
# public static final String KNATIVE_SERVING_NAMESPACE = "knative-serving";
# public static final String ISTIO_SYSTEM_NAMESPACE = "istio-system";
#
CONTROLLERS_NAMESPACE: str = getOdhOrRhoai(
"CONTROLLERS_NAMESPACE", ODH_CONTROLLERS_NAMESPACE, RHOAI_CONTROLLERS_NAMESPACE
)
DASHBOARD_ROUTE_NAME: str = getOdhOrRhoai(
"DASHBOARD_ROUTE_NAME", ODH_DASHBOARD_ROUTE_NAME, RHOAI_DASHBOARD_ROUTE_NAME
)
# public static final String DASHBOARD_CONTROLLER = getOdhOrRhoai("DASHBOARD_CONTROLLER", ODH_DASHBOARD_CONTROLLER, RHOAI_DASHBOARD_CONTROLLER);
# public static final String NOTEBOOKS_NAMESPACE = getOdhOrRhoai("NOTEBOOKS_NAMESPACE", ODH_CONTROLLERS_NAMESPACE, RHOAI_NOTEBOOKS_NAMESPACE);
# public static final String BUNDLE_OPERATOR_NAMESPACE = getOdhOrRhoai("BUNDLE_OPERATOR_NAMESPACE", ODH_BUNDLE_OPERATOR_NAME, RHOAI_OLM_OPERATOR_NAME);
# public static final String DEFAULT_DSCI_NAME = getOdhOrRhoai("DSCI_NAME", ODH_DSCI_NAME, RHOAI_DSCI_NAME);
# public static final String MONITORING_NAMESPACE = getOdhOrRhoai("MONITORING_NAMESPACE", ODH_MONITORING_NAMESPACE, RHOAI_MONITORING_NAMESPACE);
# // OLM env variables
# public static final String OLM_OPERATOR_NAME = getOdhOrRhoai("OLM_OPERATOR_NAME", ODH_OLM_OPERATOR_NAME, RHOAI_OLM_OPERATOR_NAME);
# public static final String OLM_OPERATOR_NAMESPACE = getOdhOrRhoai("OLM_OPERATOR_NAMESPACE", ODH_OLM_OPERATOR_NAMESPACE, RHOAI_OLM_OPERATOR_NAMESPACE);
# public static final String OLM_OPERATOR_DEPLOYMENT_NAME = getOdhOrRhoai("OLM_OPERATOR_DEPLOYMENT_NAME", ODH_OLM_OPERATOR_DEPLOYMENT_NAME, RHOAI_OLM_OPERATOR_DEPLOYMENT_NAME);
# public static final String OLM_APP_BUNDLE_PREFIX = getOdhOrRhoai("OLM_APP_BUNDLE_PREFIX", ODH_OLM_APP_BUNDLE_PREFIX, RHOAI_OLM_APP_BUNDLE_PREFIX);
# public static final String OLM_OPERATOR_VERSION = getOdhOrRhoai("OLM_OPERATOR_VERSION", ODH_OLM_OPERATOR_VERSION, RHOAI_OLM_OPERATOR_VERSION);
# public static final String OLM_SOURCE_NAME = getOdhOrRhoai("OLM_SOURCE_NAME", ODH_OLM_SOURCE_NAME, RHOAI_OLM_SOURCE_NAME);
# public static final String OLM_OPERATOR_CHANNEL = getOdhOrRhoai("OLM_OPERATOR_CHANNEL", ODH_OLM_OPERATOR_CHANNEL, RHOAI_OLM_OPERATOR_CHANNEL);
# public static final String OLM_UPGRADE_STARTING_OPERATOR_VERSION = getOdhOrRhoai("OLM_UPGRADE_STARTING_OPERATOR_VERSION", ODH_OLM_UPGRADE_STARTING_OPERATOR_VERSION, RHOAI_OLM_UPGRADE_STARTING_OPERATOR_VERSION);


class PodUtils:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

READINESS_TIMEOUT = 10 * 60

# consider using timeout_sampler
@staticmethod
def waitForPodsReady(client: DynamicClient, namespaceName: str, label_selector: str, expectPodsCount: int):
"""Wait for all pods in namespace to be ready
:param client:
:param namespaceName: name of the namespace
:param label_selector:
:param expectPodsCount:
"""

# it's a dynamic client with the `resource` parameter already filled in
class ResourceType(kubernetes.dynamic.Resource, kubernetes.dynamic.DynamicClient):
pass

resource: ResourceType = client.resources.get(
kind=ocp_resources.pod.Pod.kind,
api_version=ocp_resources.pod.Pod.api_version,
)

def ready() -> bool:
pods = resource.get(namespace=namespaceName, label_selector=label_selector).items
if not pods and expectPodsCount == 0:
logging.debug("All expected Pods {} in Namespace {} are ready", label_selector, namespaceName)
return True
if not pods:
logging.debug("Pods matching {}/{} are not ready", namespaceName, label_selector)
return False
if len(pods) != expectPodsCount:
logging.debug("Expected Pods {}/{} are not ready", namespaceName, label_selector)
return False
for pod in pods:
if not Readiness.isPodReady(pod) and not Readiness.isPodSucceeded(pod):
logging.debug("Pod is not ready: {}/{}", namespaceName, pod.getMetadata().getName())
return False
else:
# check all containers in pods are ready
for cs in pod.status.containerStatuses:
if not (cs.ready or cs.state.get("terminated", {}).get("reason", "") == "Completed"):
logging.debug(
f"Container {cs.getName()} of Pod {namespaceName}/{pod.getMetadata().getName()} not ready"
)
return False
logging.info("Pods matching {}/{} are ready", namespaceName, label_selector)
return True

Wait.until(
f"readiness of all Pods matching {label_selector} in Namespace {namespaceName}",
TestFrameConstants.GLOBAL_POLL_INTERVAL_MEDIUM,
PodUtils.READINESS_TIMEOUT,
ready,
)


class Wait:
@staticmethod
def until(
description: str,
pollInterval: float,
timeout: float,
ready: Callable[[], bool],
onTimeout: Callable | None = None,
):
"""or every poll (happening once each {@code pollIntervalMs}) checks if supplier {@code ready} is true.
# If yes, the wait is closed. Otherwise, waits another {@code pollIntervalMs} and tries again.
# Once the wait timeout (specified by {@code timeoutMs} is reached and supplier wasn't true until that time,
# runs the {@code onTimeout} (f.e. print of logs, showing the actual value that was checked inside {@code ready}),
# and finally throws {@link WaitException}.
# @param description information about on what we are waiting
# @param pollIntervalMs poll interval in milliseconds
# @param timeoutMs timeout specified in milliseconds
# @param ready {@link BooleanSupplier} containing code, which should be executed each poll,
# verifying readiness of the particular thing
# @param onTimeout {@link Runnable} executed once timeout is reached and
# before the {@link WaitException} is thrown."""
logging.info("Waiting for: {}", description)
deadline = time.monotonic() + timeout

exceptionMessage: str | None = None
previousExceptionMessage: str | None = None

# in case we are polling every 1s, we want to print exception after x tries, not on the first try
# for minutes poll interval will 2 be enough
exceptionAppearanceCount: int = 2 if (pollInterval // 60) > 0 else max(timeout // pollInterval // 4, 2)
exceptionCount: int = 0
newExceptionAppearance: int = 0

stackTraceError: str | None = None

while True:
try:
result: bool = ready()
except Exception as e:
exceptionMessage = str(e)

exceptionCount += 1
newExceptionAppearance += 1
if (
exceptionCount == exceptionAppearanceCount
and exceptionMessage is not None
and exceptionMessage == previousExceptionMessage
):
logging.info(f"While waiting for: {description} exception occurred: {exceptionMessage}")
# log the stacktrace
stackTraceError = traceback.format_exc()
elif (
exceptionMessage is not None
and exceptionMessage != previousExceptionMessage
and newExceptionAppearance == 2
):
previousExceptionMessage = exceptionMessage

result = False

timeLeft: float = deadline - time.monotonic()
if result:
return
if timeLeft <= 0:
if exceptionCount > 1:
logging.error("Exception waiting for: {}, {}", description, exceptionMessage)

if stackTraceError is not None:
# printing handled stacktrace
logging.error(stackTraceError)
if onTimeout is not None:
onTimeout()
waitException: WaitException = WaitException(f"Timeout after {timeout} s waiting for {description}")
logging.error(waitException)
raise waitException

sleepTime: float = min(pollInterval, timeLeft)
time.sleep(sleepTime)


class WaitException(Exception):
pass


class Readiness:
@staticmethod
def isPodReady(pod) -> bool:
Utils.checkNotNull(pod, "Pod can't be null.")

condition = ocp_resources.pod.Pod.Condition.READY
status = ocp_resources.pod.Pod.Condition.Status.TRUE
for cond in pod.get("status", {}).get("conditions", []):
if cond["type"] == condition and cond["status"].casefold() == status.casefold():
return True
return False

@staticmethod
def isPodSucceeded(pod) -> bool:
Utils.checkNotNull(pod, "Pod can't be null.")
return pod.status is not None and "Succeeded" == pod.status.phase


class Utils:
@staticmethod
def checkNotNull(value, message) -> None:
if value is None:
raise ValueError(message)


class TestFrameConstants:
GLOBAL_POLL_INTERVAL_MEDIUM = 10
39 changes: 39 additions & 0 deletions tests/workbenches/docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Callable
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this used for? does it just provide additional documentation for tests in e.g. https://github.com/opendatahub-io/opendatahub-tests/pull/94/files#diff-358f1122838e1ac22415822c5a85d419e2d111c64285e488695af1cf6f03d490R35 ?
IMHO if this is implemented we should use it across the repo to keep consistency, so this should go under a higher level directory, but it should probably be discussed beforehand. Can you attend the shift left wg meeting to do so?

import typing

T = typing.TypeVar("T")
StepRType = tuple[str, str]


def Desc(value: str) -> str:
return value


def Step(
value: str,
expected: str,
) -> StepRType:
return value, expected


def SuiteDoc(
description: str,
beforeTestSteps: set[StepRType],
afterTestSteps: set[StepRType],
) -> Callable[[T], T]:
return lambda x: x


def Contact(
name: str,
email: str,
) -> tuple[str, str]:
return name, email


def TestDoc(
description: str,
contact: str,
steps: set[StepRType],
) -> Callable[[T], T]:
return lambda x: x
Loading
Loading