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

Add user journey test for canceling a build #812

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: complicated-environment
channels:
- conda-forge
- bokeh
dependencies:
- python=3.10
- panel
- ipykernel
- ipywidgets
- ipywidgets_bokeh
- holoviews
- openjdk=17.0.9
- pyspark
- findspark
- jhsingle-native-proxy>=0.8.2
- bokeh-root-cmd>=0.1.2
- nbconvert
- pip:
- nrtk==0.3.0
- xaitk-saliency==0.7.0
- maite==0.5.0
- daml==0.44.5
- hypothesis >=6.61.0,<7.0.0
- pytest >=7.2.0,<8.0
- pytest-cov >=4.0.0,<5.0
- pytest-mock >= 3.10.0,<4.0
- pytest-snapshot >= 0.9.0
- pytest-xdist >=3.3.1,<4.0.0
- types-python-dateutil >=2.8.19,<3.0.0
- tox >=4.6.4,<5.0.0
- virtualenv-pyenv >=0.3.0,<1.0.0
- jupytext >= 1.14.0
- numpydoc >= 1.5.0
- pyright >= 1.1.280
- loguru
- torch>=2.1
- torchmetrics
- torchvision
- multiprocess
- keras
- yolov5
- smqtk-detection[centernet]
32 changes: 32 additions & 0 deletions conda-store-server/tests/user_journeys/test_user_journeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,35 @@ def test_failed_build_logs(base_url: str):
namespace,
build_request["data"]["specification"]["name"],
)


@pytest.mark.user_journey
def test_cancel_build(base_url: str):
"""Test that a user cancel a build in progress."""
api = utils.API(base_url=base_url)
namespace = "default"
build_id = api.create_environment(
namespace,
"tests/user_journeys/test_data/complicated_environment.yaml",
wait=False,
).json()["data"]["build_id"]

assert api.get_build_status(build_id) in [
utils.BuildStatus.QUEUED,
utils.BuildStatus.BUILDING,
]
api.cancel_build(build_id)

def check_status():
status = api.get_build_status(build_id)
if status in [utils.BuildStatus.QUEUED, utils.BuildStatus.BUILDING]:
return False

if status in [utils.BuildStatus.COMPLETED, utils.BuildStatus.FAILED]:
raise ValueError(
f"Build {build_id} {status.value.lower()}, but should have been canceled."
)

return status == utils.BuildStatus.CANCELED

utils.wait_for_condition(check_status, timeout=60, interval=1)
117 changes: 81 additions & 36 deletions conda-store-server/tests/user_journeys/utils/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import uuid

from enum import Enum
from typing import Any, Optional, Union
from typing import Any, Callable, Optional, Union

import requests
import utils.time_utils as time_utils
Expand Down Expand Up @@ -94,22 +94,13 @@ def _login(self, username: str, password: str) -> None:
data = token_response.json()
self.token = data["data"]["token"]

def create_namespace(
self,
namespace: Union[str, None] = None,
max_iterations: int = 100,
sleep_time: int = 5,
) -> requests.Response:
def create_namespace(self, namespace: Union[str, None] = None) -> requests.Response:
"""Create a namespace.

Parameters
----------
namespace : str
Name of the namespace to create. If None, use a random namespace name
max_iterations : int
Max number of times to check whether the namespace was created before failing
sleep_time : int
Seconds to wait between each status check

Returns
-------
Expand All @@ -120,18 +111,7 @@ def create_namespace(
namespace = self.gen_random_namespace()

self._make_request(f"api/v1/namespace/{namespace}", method="POST")
for i in range(max_iterations):
response = self._make_request(f"api/v1/namespace/{namespace}")
status = NamespaceStatus(response.json()["status"])
if status in [NamespaceStatus.OK, NamespaceStatus.ERROR]:
return response

time.sleep(sleep_time)

raise TimeoutError(
f"Timed out waiting to create namespace {namespace}. Current response: "
f"{response.json()}"
)
return self._make_request(f"api/v1/namespace/{namespace}")

def create_token(
self, namespace: str, role: str, default_namespace: str = "default"
Expand All @@ -150,6 +130,7 @@ def create_environment(
specification_path: str,
max_iterations: int = 100,
sleep_time: int = 5,
wait: bool = True,
) -> requests.Response:
"""Create an environment.

Expand All @@ -163,6 +144,10 @@ def create_environment(
Max number of times to check whether the build completed before failing
sleep_time : int
Seconds to wait between each status check
wait : bool
If True, wait for the build to complete, fail, or be canceled before
returning a response. If False, return the response from the specification
POST immediately without waiting

Returns
-------
Expand All @@ -178,24 +163,23 @@ def create_environment(
method="POST",
json_data={"namespace": namespace, "specification": specification_content},
)
if not wait:
return response

build_id = response.json()["data"]["build_id"]
for i in range(max_iterations):
response = self._make_request(f"api/v1/build/{build_id}/")
status = BuildStatus(response.json()["data"]["status"])

if status in [
BuildStatus.COMPLETED,
def check_status():
status = self.get_build_status(build_id)
if status in [BuildStatus.QUEUED, BuildStatus.BUILDING]:
return False
return status in [
BuildStatus.FAILED,
BuildStatus.CANCELED,
]:
return response

time.sleep(sleep_time)
BuildStatus.COMPLETED,
]

raise TimeoutError(
f"Timed out waiting to create namespace {namespace}. Current response: "
f"{response.json()}"
)
wait_for_condition(check_status, timeout=120, interval=1)
return self._make_request(f"api/v1/build/{build_id}/")

def delete_environment(
self, namespace: str, environment_name: str
Expand Down Expand Up @@ -309,3 +293,64 @@ def get_environment(self, namespace: str, environment: str) -> dict[str, Any]:
return self._make_request(
f"api/v1/environment/{namespace}/{environment}/"
).json()["data"]

def cancel_build(self, build_id: int) -> requests.Response:
"""Cancel a build in progress.

Parameters
----------
build_id : int
ID of the build to cancel

Returns
-------
requests.Response
Response from the server
"""
return self._make_request(f"api/v1/build/{build_id}/cancel/", method="PUT")

def get_build_status(self, build_id: int) -> BuildStatus:
"""Get the status of a build as a BuildStatus instance.

Parameters
----------
build_id : int
ID of the build to get the status for

Returns
-------
BuildStatus
Build status for the given build ID
"""
response = self._make_request(f"api/v1/build/{build_id}/")
return BuildStatus(response.json()["data"]["status"])


def wait_for_condition(
condition: Callable[[], bool], timeout: int = 60, interval: int = 1
):
"""Call `condition` until it returns `True`.

`condition` will be called every `interval` seconds up to a maximum of `timeout`
seconds, at which point a ValueError is raised.

Parameters
----------
condition : Callable[[], bool]
Function to call until True is returned
timeout : int
Number of seconds to continue calling `condition` for before timing out
interval : int
Number of seconds between consecutive calls
"""
initial_time = time.time()
while time.time() - initial_time < timeout:
result = condition()
if result:
return

time.sleep(interval)

raise ValueError(
f"Timeout after {timeout}s waiting for condition. Last result: {result}"
)
Loading