diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml new file mode 100644 index 00000000..788eb2b9 --- /dev/null +++ b/.github/workflows/api-tests.yml @@ -0,0 +1,34 @@ +name: API smoke tests + +on: + push: + branches: [ master, dev*, feature/docker*, feature/repo*, jupyterhub-update ] + pull_request: + branches: [ master, dev*, feature/docker*, feature/repo*, jupyterhub-update ] + +jobs: + + build: + + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + python-version: [ "3.12" ] + runs-on: [ ubuntu-latest ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install pytest requests==2.31.0 + + - name: Run API tests + run: | + pytest tests/test_apis.py -vs diff --git a/applications/workspaces/server/workspaces/service/osbrepository/adapters/biomodelsadapter.py b/applications/workspaces/server/workspaces/service/osbrepository/adapters/biomodelsadapter.py index 7bcc1ff3..dbbb0643 100644 --- a/applications/workspaces/server/workspaces/service/osbrepository/adapters/biomodelsadapter.py +++ b/applications/workspaces/server/workspaces/service/osbrepository/adapters/biomodelsadapter.py @@ -19,13 +19,13 @@ class BiomodelsAdapter: """ Adapter for Biomodels - https://www.ebi.ac.uk/biomodels/ + https://www.biomodels.org/ """ def __init__(self, osbrepository, uri=None): self.osbrepository = osbrepository self.uri = uri if uri else osbrepository.uri - self.api_url = "https://www.ebi.ac.uk/biomodels" + self.api_url = "https://www.biomodels.org" try: self.model_id = re.search( diff --git a/local-scripts/osbv2-local.sh b/local-scripts/osbv2-local.sh index c5d3c190..b34b6c60 100755 --- a/local-scripts/osbv2-local.sh +++ b/local-scripts/osbv2-local.sh @@ -15,7 +15,8 @@ CLOUD_HARNESS_DIR="${CLOUD_HARNESS_DIR_LOCATION}/cloud-harness" CLOUD_HARNESS_DEFAULT="develop" CLOUD_HARNESS_BRANCH="" SKAFFOLD="skaffold" -SKAFFOLD_MAX_VERSION="2.14.2" +SKAFFOLD_MAX_VERSION="2.15.0" +HELM_MAX_VERSION="4.0.0" # Application to deploy DEPLOYMENT_APP="" @@ -54,7 +55,7 @@ start_minikube () { echo "🍏 Assuming Docker is already running on OS: $(uname -s)" fi - echo "-> starting minkube" + echo "-> starting minikube" if minikube status then echo "-> Minikube is already running: not restarting it" @@ -66,10 +67,10 @@ start_minikube () { minikube addons enable metrics-server || notify_fail "Failed: ingress add on" echo "-> setting up ${OSB_NAMESPACE} namespace" kubectl get ns ${OSB_NAMESPACE} || kubectl create ns ${OSB_NAMESPACE} || notify_fail "Failed: ns set up" - kubectl config set-context NAME --namespace=${OSB_NAMESPACE} || notify_fail "Failed: ns set up" + kubectl config set-context --current --namespace=${OSB_NAMESPACE} || notify_fail "Failed: ns set up" echo "-> setting up minikube docker env" - eval $(minikube docker-env) || notify_fail "Failed: env setup" + eval "$(minikube docker-env)" || notify_fail "Failed: env setup" fi } @@ -86,7 +87,7 @@ deploy_live () { LIVE="YES" - pushd $OSB_DIR + pushd $OSB_DIR || exit 1 echo "-> deploying live configuration" start_minikube @@ -97,9 +98,9 @@ deploy_live () { echo echo "-> Deploying with helm: helm install -n ${OSB_NAMESPACE} osb deployment/helm" - helm install -n ${OSB_NAMESPACE} osb deployment/helm + helm install --debug -n ${OSB_NAMESPACE} osb deployment/helm - popd + popd || exit 1 } show_deployment_status () { @@ -137,29 +138,43 @@ deploy () { skaffold_version="$($SKAFFOLD version)" - if [ $(get_version ${skaffold_version:1}) -gt $(get_version $SKAFFOLD_MAX_VERSION) ] + if [ "$(get_version ${skaffold_version:1})" -gt "$(get_version $SKAFFOLD_MAX_VERSION)" ] then echo "-> Found Skaffold version: ${skaffold_version:1}" - echo "-> Skaffold version <= ${SKAFFOLD_MAX_VERSION} is currently required" + echo "-> Skaffold version < ${SKAFFOLD_MAX_VERSION} is currently required" echo "-> Please install it from: https://github.com/GoogleContainerTools/skaffold/releases/tag/v${SKAFFOLD_MAX_VERSION}" echo "-> See: https://github.com/GoogleContainerTools/skaffold/issues/9788" exit 1 + else + echo "Got skaffold version: ${skaffold_version}" fi - pushd $OSB_DIR + + helm_version="$(helm version | grep -o 'Version:"v[^"]*"' | cut -d'"' -f2 )" + + if [ "$(get_version ${helm_version:1})" -gt "$(get_version $HELM_MAX_VERSION)" ] + then + echo "-> Found helm version: ${skaffold_version:1}" + echo "-> helm version < ${HELM_MAX_VERSION} is currently required" + echo "-> Please install it from: https://github.com/helm/helm/releases#release-v${HELM_MAX_VERSION}" + exit 1 + else + echo "Got helm version: ${helm_version}" + fi + pushd $OSB_DIR || exit 1 echo "-> deploying" start_minikube harness_deployment echo "-> running skaffold" - $SKAFFOLD dev --cleanup=false || { notify_fail "Failed: skaffold" ; minikube stop; } - #$SKAFFOLD dev || notify_fail "Failed: skaffold" - popd + #$SKAFFOLD dev --cleanup=false || { notify_fail "Failed: skaffold" ; minikube stop; } + $SKAFFOLD dev || notify_fail "Failed: skaffold" + popd || exit 1 } list_versions () { - if ! command -v harness-deployment 2>&1 >/dev/null ; then + if ! command -v harness-deployment >/dev/null 2>&1 ; then echo "cloud-harness is required but were not found." echo "To install cloud-harness, please see the -u/-U options" exit 1 @@ -189,7 +204,7 @@ harness_deployment() { # use -e dev for that, but that will send e-mails to Metacell folks # suggested: create a new file in deploy/values-something.yaml where you use # your e-mail address, and then use `-e something` to use these values. - pushd $OSB_DIR + pushd $OSB_DIR || exit 1 if [ "YES" == "$LIVE" ] then echo "-> harnessing live configuration deployment, and deploying" @@ -198,7 +213,7 @@ harness_deployment() { echo "-> harnessing development deployment" harness-deployment ../cloud-harness . -l -n ${OSB_NAMESPACE} -d osb.local -dtls -e "local" ${DEPLOYMENT_APP:+-i $DEPLOYMENT_APP} || notify_fail "Failed: harness-deployment (dev)" fi - popd + popd || exit 1 } notify_fail () { @@ -213,20 +228,35 @@ notify_fail () { update_cloud_harness() { echo "Updating cloud harness" - CLOUD_HARNESS_PACKAGES=$(pip list | grep cloud | tr -s " " | cut -d " " -f1 | tr '\n' ' ') - pip uninstall ${CLOUD_HARNESS_PACKAGES} -y || echo "No cloud harness packages installed" + if command -v uv >/dev/null + then + CLOUD_HARNESS_PACKAGES=$(uv pip list | grep cloud | tr -s " " | cut -d " " -f1 | tr '\n' ' ') + uv pip uninstall ${CLOUD_HARNESS_PACKAGES} || echo "No cloud harness packages installed" + else + CLOUD_HARNESS_PACKAGES=$(pip list | grep cloud | tr -s " " | cut -d " " -f1 | tr '\n' ' ') + pip uninstall "${CLOUD_HARNESS_PACKAGES}" -y || echo "No cloud harness packages installed" + fi if ! [ -d "${CLOUD_HARNESS_DIR}" ] then echo "Cloud harness folder does not exist. Cloning" - pushd "${CLOUD_HARNESS_DIR_LOCATION}" && git clone "${CLOUD_HARNESS_URL}" && popd + pushd "${CLOUD_HARNESS_DIR_LOCATION}" && git clone "${CLOUD_HARNESS_URL}" && popd || exit 1 + fi + if command -v uv >/dev/null + then + pushd "$CLOUD_HARNESS_DIR" && git clean -dfx && git fetch && git checkout "${CLOUD_HARNESS_BRANCH}" && git pull && uv pip install -r requirements.txt && popd || exit 1 + else + pushd "$CLOUD_HARNESS_DIR" && git clean -dfx && git fetch && git checkout "${CLOUD_HARNESS_BRANCH}" && git pull && pip install -r requirements.txt && popd || exit 1 fi - pushd "$CLOUD_HARNESS_DIR" && git clean -dfx && git fetch && git checkout ${CLOUD_HARNESS_BRANCH} && git pull && pip install -r requirements.txt && popd } activate_venv() { if [ -f "${VENV_DIR}/bin/activate" ] then source "${VENV_DIR}/bin/activate" + elif command -v uv >/dev/null + then + echo "No virtual environment found at ${VENV_DIR}. Creating (uv)" + uv venv --python "${PY_VERSION}" "${VENV_DIR}" && source "${VENV_DIR}/bin/activate" else echo "No virtual environment found at ${VENV_DIR}. Creating" ${PY_VERSION} -m venv "${VENV_DIR}" && source "${VENV_DIR}/bin/activate" @@ -245,7 +275,7 @@ print_versions() { echo -e "\n** minikube **" minikube version echo -e "\n** cloud harness **" - pushd "${CLOUD_HARNESS_DIR}" && git log --oneline | head -1 && popd + pushd "${CLOUD_HARNESS_DIR}" && git log --oneline | head -1 && popd || exit 1 echo -e "\n** helm **" helm version echo -e "\n** skaffold **" @@ -257,7 +287,7 @@ print_versions() { } clean () { - pushd $OSB_DIR + pushd $OSB_DIR || exit 1 echo "-> Cleaning up all images." #docker image prune --all docker builder prune --all @@ -266,7 +296,7 @@ clean () { minikube delete #docker image prune --all docker builder prune --all - popd + popd || exit 1 } usage () { @@ -355,7 +385,6 @@ do exit 0 ;; l) - CLOUD_HARNESS_BRANCH="${OPTARG}" activate_venv deploy_live exit 0 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..69c803ac --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + network: marks tests as requiring network access (deselect with '-m "not network"') diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_apis.py b/tests/test_apis.py new file mode 100644 index 00000000..3db49d20 --- /dev/null +++ b/tests/test_apis.py @@ -0,0 +1,218 @@ +""" +Smoke tests to verify external APIs are up and responding. + +These tests check that the APIs we depend on (GitHub, Dandi, Figshare, BioModels) +are reachable and returning valid responses with the expected fields. + +If an API response structure changes, these tests will fail and alert us. + +Run with: pytest tests/test_apis.py -v +Skip network tests: pytest tests/test_apis.py -v -m "not network" +""" + +import pytest +import requests + + +TIMEOUT = 10 # seconds + + +@pytest.mark.network +def test_github_api(): + """Test GitHub API is reachable and returns valid repository info.""" + url = "https://api.github.com/repos/OpenSourceBrain/OSBv2" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by GitHubAdapter + assert "name" in data + assert data["name"] == "OSBv2" + assert "default_branch" in data + + +@pytest.mark.network +def test_github_api_branches(): + """Test GitHub branches endpoint returns expected fields.""" + url = "https://api.github.com/repos/OpenSourceBrain/OSBv2/branches?per_page=5" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + # Fields accessed by GitHubAdapter.get_contexts() + assert "name" in data[0] + + +@pytest.mark.network +def test_github_api_tags(): + """Test GitHub tags endpoint returns expected fields.""" + url = "https://api.github.com/repos/OpenSourceBrain/OSBv2/tags?per_page=5" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # Fields accessed by GitHubAdapter.get_contexts() + if data: + assert "name" in data[0] + + +@pytest.mark.network +def test_github_api_tree(): + """Test GitHub git tree endpoint returns expected fields.""" + url = "https://api.github.com/repos/OpenSourceBrain/OSBv2/git/trees/develop?recursive=1" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by GitHubAdapter.get_resources() + assert "tree" in data + assert isinstance(data["tree"], list) + assert len(data["tree"]) > 0 + tree_item = data["tree"][0] + assert "path" in tree_item + assert "sha" in tree_item + + +@pytest.mark.network +def test_dandi_api(): + """Test Dandi API is reachable and returns valid dandiset info.""" + url = "https://api.dandiarchive.org/api/dandisets/000029" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by DandiAdapter.get_info() + assert "identifier" in data + assert data["identifier"] == "000029" + assert "most_recent_published_version" in data + assert "draft_version" in data + + +@pytest.mark.network +def test_dandi_api_versions(): + """Test Dandi versions endpoint returns expected fields.""" + url = "https://api.dandiarchive.org/api/dandisets/000029/versions/" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by DandiAdapter.get_contexts() + assert "results" in data + assert isinstance(data["results"], list) + assert len(data["results"]) > 0 + version = data["results"][0] + assert "version" in version + + +@pytest.mark.network +def test_dandi_api_version_info(): + """Test Dandi version info endpoint returns expected fields.""" + # First get a version + versions_url = "https://api.dandiarchive.org/api/dandisets/000029/versions/" + versions_response = requests.get(versions_url, timeout=TIMEOUT) + assert versions_response.status_code == 200 + version = versions_response.json()["results"][0]["version"] + + # Then get info for that version + url = f"https://api.dandiarchive.org/api/dandisets/000029/versions/{version}/info/" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by DandiAdapter._get_dandi_info() + assert "metadata" in data + metadata = data["metadata"] + assert "keywords" in metadata + assert "description" in metadata + + +@pytest.mark.network +def test_dandi_api_assets(): + """Test Dandi assets/paths endpoint returns expected fields.""" + url = "https://api.dandiarchive.org/api/dandisets/000029/versions/draft/assets/paths/?path_prefix=" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by DandiAdapter.__retrieve_folder_contents() + assert "results" in data + assert isinstance(data["results"], list) + assert len(data["results"]) > 0 + asset = data["results"][0] + assert "path" in asset + assert "asset" in asset + assert "aggregate_size" in asset + + +@pytest.mark.network +def test_figshare_api(): + """Test Figshare API is reachable and returns valid article info.""" + url = "https://api.figshare.com/v2/articles/31292986" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by FigShareAdapter.get_info() + assert "title" in data + assert "id" in data + assert "tags" in data + assert "description" in data + + +@pytest.mark.network +def test_figshare_api_versions(): + """Test Figshare versions endpoint returns expected fields.""" + url = "https://api.figshare.com/v2/articles/31292986/versions" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by FigShareAdapter.get_contexts() + assert isinstance(data, list) + assert len(data) > 0 + assert "version" in data[0] + + +@pytest.mark.network +def test_figshare_api_files(): + """Test Figshare files endpoint returns expected fields.""" + url = "https://api.figshare.com/v2/articles/31292986/files?page=1&page_size=10" + response = requests.get(url, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by FigShareAdapter.get_resources() + assert isinstance(data, list) + assert len(data) > 0 + file = data[0] + assert "name" in file + assert "download_url" in file + assert "size" in file + + +@pytest.mark.network +def test_biomodels_api(): + """Test BioModels API is reachable and returns valid model info.""" + url = "https://www.biomodels.org/BIOMD0000000001" + response = requests.get(url, params={"format": "json"}, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by BiomodelsAdapter.get_info() and get_contexts() + assert "name" in data + assert len(data["name"]) > 0 + assert "description" in data + assert "format" in data + assert "name" in data["format"] + assert "history" in data + assert "revisions" in data["history"] + assert isinstance(data["history"]["revisions"], list) + assert len(data["history"]["revisions"]) > 0 + assert "version" in data["history"]["revisions"][0] + + +@pytest.mark.network +def test_biomodels_api_files(): + """Test BioModels files endpoint returns expected fields.""" + url = "https://www.biomodels.org/model/files/BIOMD0000000001.1" + response = requests.get(url, params={"format": "json"}, timeout=TIMEOUT) + assert response.status_code == 200 + data = response.json() + # Fields accessed by BiomodelsAdapter._get_filelist() + assert "main" in data or "additional" in data + files = data.get("main", []) + data.get("additional", []) + assert len(files) > 0 + file = files[0] + assert "name" in file + assert "fileSize" in file