diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d5b9ead5d..c078552b3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -62,7 +62,7 @@ jobs: - name: "Unit tests ✅" run: | - pytest -m "not extended_prefix" tests + pytest -m "not extended_prefix and not user_journey" tests # https://github.com/actions/runner-images/issues/1052 - name: "Windows extended prefix unit tests ✅" @@ -125,6 +125,10 @@ jobs: export PYTHONPATH=$PYTHONPATH:$PWD pytest ../tests/test_api.py ../tests/test_metrics.py + - name: "Run user journey tests ✅" + run: | + pytest -m "user_journey" + - name: "Get Docker logs 🔍" if: ${{ failure() }} run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64c2419e7..164a8bf16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,14 +13,14 @@ repos: rev: 23.9.1 hooks: - id: black - exclude: "examples|tests|docs" + exclude: "examples|tests/assets" - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. rev: "v0.0.289" hooks: - id: ruff - exclude: "examples|tests|docs" + exclude: "examples|tests/assets" args: ["--fix"] - repo: https://github.com/pycqa/isort diff --git a/CHANGELOG.md b/CHANGELOG.md index f088411e1..e75f2921d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The project changed to `CalVer` in September 2023. +## [2024.3.1] - 2024-03-12 + +([full changelog](https://github.com/conda-incubator/conda-store/compare/2024.1.1...2024.3.1)) + +## Added + +* Add upstream contribution policy by @pavithraes in https://github.com/conda-incubator/conda-store/pull/722 +* Pass `CONDA_OVERRIDE_CUDA` to `with_cuda` of conda-lock by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/721 +* Add backwards compatibility policy by @dcmcand in https://github.com/conda-incubator/conda-store/pull/687 +* add how to test section to PR template by @dcmcand in https://github.com/conda-incubator/conda-store/pull/743 +* Add extended-length prefix support by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/713 +* Generate `constructor` artifacts by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/714 +* Add support for the `editor` role by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/738 +* Add a test for parallel builds, fix race conditions due to the shared conda cache by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/745 +* Add user journey test by @dcmcand in https://github.com/conda-incubator/conda-store/pull/760 +* Add status `CANCELED` by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/747 +* [DOC] Document setting environment variable by @pavithraes in https://github.com/conda-incubator/conda-store/pull/765 + +## Fixed + +* Log address and port, show exception trace from `uvicorn.run` by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/708 +* Check if worker is initialized by @nkaretnikov in https://github.com/conda-incubator/conda-store/pull/705 + +## Contributors to this release + +([GitHub contributors page for this release](https://github.com/conda-incubator/conda-store/graphs/contributors?from=2024-01-30&to=2024-03-12&type=c)) + +[@nkaretnikov](https://github.com/search?q=repo%3Aconda-incubator%2Fconda-store+involves%3Ankaretnikov+updated%3A2024-01-30..2024-03-12&type=Issues) | [@dcmcand](https://github.com/search?q=repo%3Aconda-incubator%2Fconda-store+involves%3Adcmcand+updated%3A2024-01-30..2024-03-12&type=Issues) | [@pavithraes](https://github.com/search?q=repo%3Aconda-incubator%2Fconda-store+involves%3Apavithraes+updated%3A2024-01-30..2024-03-12&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Aconda-incubator%2Fconda-store+involves%3Adependabot+updated%3A2024-01-30..2024-03-12&type=Issues)| [@trallard](https://github.com/search?q=repo%3Aconda-incubator%2Fconda-store+involves%3Atrallard+updated%3A2024-01-30..2024-03-12&type=Issues) + ## [2024.1.1] - 2024-01-30 ([full changelog](https://github.com/conda-incubator/conda-store/compare/2023.10.1...ec606641f6d0bb7bde39b2e9f11cf515077feee8)) diff --git a/conda-store-server/conda_store_server/__init__.py b/conda-store-server/conda_store_server/__init__.py index 59b68eb7d..5737a0e60 100644 --- a/conda-store-server/conda_store_server/__init__.py +++ b/conda-store-server/conda_store_server/__init__.py @@ -2,7 +2,7 @@ import typing from pathlib import Path -__version__ = "2024.1.1" +__version__ = "2024.3.1" CONDA_STORE_DIR = Path.home() / ".conda-store" diff --git a/conda-store-server/conda_store_server/action/download_packages.py b/conda-store-server/conda_store_server/action/download_packages.py index c775eb648..93273f3ba 100644 --- a/conda-store-server/conda_store_server/action/download_packages.py +++ b/conda-store-server/conda_store_server/action/download_packages.py @@ -1,10 +1,25 @@ +import os import pathlib import shutil +import tempfile import typing +# This import is needed to avoid the following error on conda imports: +# AttributeError: 'Logger' object has no attribute 'trace' +import conda.gateways.logging # noqa import conda_package_handling.api import conda_package_streaming.url import filelock +from conda.base.constants import PACKAGE_CACHE_MAGIC_FILE +from conda.common.path import expand, strip_pkg_extension +from conda.core.package_cache_data import ( + PackageCacheRecord, + PackageRecord, + getsize, + read_index_json, + write_as_json_to_file, +) +from conda.gateways.disk.update import touch from conda_store_server import action, conda_utils @@ -28,14 +43,149 @@ def action_fetch_and_extract_conda_packages( file_path = pkgs_dir / filename count_message = f"{packages_searched} of {total_packages}" with filelock.FileLock(str(lock_filename)): + # This magic file, which is currently set to "urls.txt", is used + # to check cache permissions in conda, see _check_writable in + # PackageCacheData. + # + # Sometimes this file is not yet created while this action is + # running. Without this magic file, PackageCacheData cache + # query functions, like query_all, will return nothing. + # + # If the magic file is not present, this error might be thrown + # during the lockfile install action: + # + # File "/opt/conda/lib/python3.10/site-packages/conda/misc.py", line 110, in explicit + # raise AssertionError("No package cache records found") + # + # The code below is from create_package_cache_directory in + # conda, which creates the package cache, but we only need the + # magic file part here: + cache_magic_file = pkgs_dir / PACKAGE_CACHE_MAGIC_FILE + if not cache_magic_file.exists(): + sudo_safe = expand(pkgs_dir).startswith(expand("~")) + touch(cache_magic_file, mkdir=True, sudo_safe=sudo_safe) + if file_path.exists(): context.log.info(f"SKIPPING {filename} | FILE EXISTS\n") else: - context.log.info(f"DOWNLOAD {filename} | {count_message}\n") - ( - filename, - conda_package_stream, - ) = conda_package_streaming.url.conda_reader_for_url(url) - with file_path.open("wb") as f: - shutil.copyfileobj(conda_package_stream, f) - conda_package_handling.api.extract(str(file_path)) + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_dir = pathlib.Path(tmp_dir) + file_path = tmp_dir / filename + file_path_str = str(file_path) + extracted_dir = pathlib.Path( + strip_pkg_extension(file_path_str)[0] + ) + extracted_dir_str = str(extracted_dir) + context.log.info(f"DOWNLOAD {filename} | {count_message}\n") + ( + filename, + conda_package_stream, + ) = conda_package_streaming.url.conda_reader_for_url(url) + with file_path.open("wb") as f: + shutil.copyfileobj(conda_package_stream, f) + conda_package_handling.api.extract( + file_path_str, dest_dir=extracted_dir + ) + + # This code is needed to avoid failures when building in + # parallel while using the shared cache. + # + # Package tarballs contain the info/index.json file, + # which is used by conda to create the + # info/repodata_record.json file. The latter is used to + # interact with the cache. _make_single_record from + # PackageCacheData would create the repodata json file + # if it's not present, which would happen in conda-store + # during the lockfile install action. The repodata file + # is not created if it already exists. + # + # The code that does that in conda is similar to the code + # below. However, there is an important difference. The + # code in conda would fail to read the url and return None + # here: + # + # url = self._urls_data.get_url(package_filename) + # + # And that would result in the channel field of the json + # file being set to "". This is a problem because + # the channel is used when querying cache entries, via + # match_individual from MatchSpec, which would always result + # in a mismatch because the proper channel value is + # different. + # + # That would make conda think that the package is not + # available in the cache, so it would try to download it + # outside of this action, where no locking is implemented. + # + # As of now, conda's cache is not atomic, so the same + # dependencies requested by different builds would overwrite + # each other causing random failures during the build + # process. + # + # To avoid this problem, the code below does what the code + # in conda does but also sets the url properly, which would + # make the channel match properly during the query process + # later. So no dependencies would be downloaded outside of + # this action and cache corruption is prevented. + # + # To illustrate, here's a diff of an old conda entry, which + # didn't work, versus the new one created by this action: + # + # --- /tmp/old.txt 2024-02-05 01:08:16.879751010 +0100 + # +++ /tmp/new.txt 2024-02-05 01:08:02.919319887 +0100 + # @@ -2,7 +2,7 @@ + # "arch": "x86_64", + # "build": "conda_forge", + # "build_number": 0, + # - "channel": "", + # + "channel": "https://conda.anaconda.org/conda-forge/linux-64", + # "constrains": [], + # "depends": [], + # "features": "", + # @@ -15,5 +15,6 @@ + # "subdir": "linux-64", + # "timestamp": 1578324546067, + # "track_features": "", + # + "url": "https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2", + # "version": "0.1" + # } + # + # Also see the comment above about the cache magic file. + # Without the magic file, cache queries would fail even if + # repodata_record.json files have proper channels specified. + + # This file is used to parse cache records via PackageCacheRecord in conda + repodata_file = extracted_dir / "info" / "repodata_record.json" + + raw_json_record = read_index_json(extracted_dir) + fn = os.path.basename(file_path_str) + md5 = package["hash"]["md5"] + size = getsize(file_path_str) + + package_cache_record = PackageCacheRecord.from_objects( + raw_json_record, + url=url, + fn=fn, + md5=md5, + size=size, + package_tarball_full_path=file_path_str, + extracted_package_dir=extracted_dir_str, + ) + + repodata_record = PackageRecord.from_objects( + package_cache_record + ) + write_as_json_to_file(repodata_file, repodata_record) + + # This is to ensure _make_single_record in conda never + # sees the extracted package directory without our + # repodata_record file being there. Otherwise, conda + # would attempt to create the repodata file, with the + # channel field set to "", which would make the + # above code pointless. Using symlinks here would be + # better since those are atomic on Linux, but I don't + # want to create any permanent directories on the + # filesystem. + shutil.rmtree(pkgs_dir / extracted_dir.name, ignore_errors=True) + shutil.move(extracted_dir, pkgs_dir / extracted_dir.name) + shutil.move(file_path, pkgs_dir / file_path.name) diff --git a/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py b/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py new file mode 100644 index 000000000..8c30d3b50 --- /dev/null +++ b/conda-store-server/conda_store_server/alembic/versions/03c839888c82_add_canceled_status.py @@ -0,0 +1,44 @@ +"""add canceled status + +Revision ID: 03c839888c82 +Revises: 57cd11b949d5 +Create Date: 2024-01-29 03:56:36.889909 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "03c839888c82" +down_revision = "57cd11b949d5" +branch_labels = None +depends_on = None + + +# Migrating from/to VARCHAR having the same length might look strange, but it +# serves a purpose. This will be a no-op in SQLite because it represents Python +# enums as VARCHAR, but it will convert the enum in PostgreSQL to VARCHAR. The +# old type is set to VARCHAR here because you can cast an enum to VARCHAR, which +# is needed for the migration to work. In the end, both DBs will use VARCHAR to +# represent the Python enum, which makes it easier to support both DBs at the +# same time. +def upgrade(): + with op.batch_alter_table( + "build", + schema=None, + ) as batch_op: + batch_op.alter_column( + "status", + existing_type=sa.VARCHAR(length=9), + type_=sa.VARCHAR(length=9), + existing_nullable=False, + ) + if not str(op.get_bind().engine.url).startswith("sqlite"): + op.execute("DROP TYPE IF EXISTS buildstatus") + + +def downgrade(): + # There are foreign key constraints linking build ids to other tables. So + # just mark the builds as failed, which was the status previously used for + # canceled builds + op.execute("UPDATE build SET status = 'FAILED' WHERE status = 'CANCELED'") diff --git a/conda-store-server/conda_store_server/build.py b/conda-store-server/conda_store_server/build.py index d0257be88..f2b349b08 100644 --- a/conda-store-server/conda_store_server/build.py +++ b/conda-store-server/conda_store_server/build.py @@ -48,6 +48,15 @@ def set_build_failed( db.commit() +def set_build_canceled( + db: Session, build: orm.Build, status_info: typing.Optional[str] = None +): + build.status = schema.BuildStatus.CANCELED + build.status_info = status_info + build.ended_on = datetime.datetime.utcnow() + db.commit() + + def set_build_completed(db: Session, conda_store, build: orm.Build): build.status = schema.BuildStatus.COMPLETED build.ended_on = datetime.datetime.utcnow() @@ -65,17 +74,22 @@ def set_build_completed(db: Session, conda_store, build: orm.Build): def build_cleanup( - db: Session, conda_store, build_ids: typing.List[str] = None, reason: str = None + db: Session, + conda_store, + build_ids: typing.List[str] = None, + reason: str = None, + is_canceled: bool = False, ): """Walk through all builds in BUILDING state and check that they are actively running Build can get stuck in the building state due to worker spontaineously dying due to memory errors, killing container, etc. """ + status = "CANCELED" if is_canceled else "FAILED" reason = ( reason - or """ -Build marked as FAILED on cleanup due to being stuck in BUILDING state + or f""" +Build marked as {status} on cleanup due to being stuck in BUILDING state and not present on workers. This happens for several reasons: build is canceled, a worker crash from out of memory errors, worker was killed, or error in conda-store @@ -113,7 +127,7 @@ def build_cleanup( ) ): conda_store.log.warning( - f"marking build {build.id} as FAILED since stuck in BUILDING state and not present on workers" + f"marking build {build.id} as {status} since stuck in BUILDING state and not present on workers" ) append_to_logs( db, @@ -121,7 +135,10 @@ def build_cleanup( build, reason, ) - set_build_failed(db, build) + if is_canceled: + set_build_canceled(db, build) + else: + set_build_failed(db, build) def build_conda_environment(db: Session, conda_store, build): diff --git a/conda-store-server/conda_store_server/schema.py b/conda-store-server/conda_store_server/schema.py index 0db0e535a..52142b4a9 100644 --- a/conda-store-server/conda_store_server/schema.py +++ b/conda-store-server/conda_store_server/schema.py @@ -156,6 +156,7 @@ class BuildStatus(enum.Enum): BUILDING = "BUILDING" COMPLETED = "COMPLETED" FAILED = "FAILED" + CANCELED = "CANCELED" class BuildArtifact(BaseModel): diff --git a/conda-store-server/conda_store_server/server/templates/build.html b/conda-store-server/conda_store_server/server/templates/build.html index bd36351e6..35dd76fdb 100644 --- a/conda-store-server/conda_store_server/server/templates/build.html +++ b/conda-store-server/conda_store_server/server/templates/build.html @@ -61,7 +61,7 @@

Conda Packages {% endif %} -{% if build.status.value in ['BUILDING', 'COMPLETED', 'FAILED'] %} +{% if build.status.value in ['BUILDING', 'COMPLETED', 'FAILED', 'CANCELED'] %}

Conda Environment Artifacts

@@ -89,7 +89,7 @@

Conda Environment Artifacts

{% endif %} -{% if build.status.value in ['BUILDING', 'COMPLETED', 'FAILED'] %} +{% if build.status.value in ['BUILDING', 'COMPLETED', 'FAILED', 'CANCELED'] %}
Full Logs diff --git a/conda-store-server/conda_store_server/server/views/api.py b/conda-store-server/conda_store_server/server/views/api.py index 6cd3ca7a9..72c6826cf 100644 --- a/conda-store-server/conda_store_server/server/views/api.py +++ b/conda-store-server/conda_store_server/server/views/api.py @@ -986,8 +986,9 @@ async def api_put_build_cancel( tasks.task_cleanup_builds.si( build_ids=[build_id], reason=f""" - build {build_id} marked as FAILED due to being canceled from the REST API + build {build_id} marked as CANCELED due to being canceled from the REST API """, + is_canceled=True, ).apply_async(countdown=5) return { diff --git a/conda-store-server/conda_store_server/utils.py b/conda-store-server/conda_store_server/utils.py index 386561599..10051de4a 100644 --- a/conda-store-server/conda_store_server/utils.py +++ b/conda-store-server/conda_store_server/utils.py @@ -8,6 +8,8 @@ import sys import time +from filelock import FileLock + class CondaStoreError(Exception): @property @@ -20,9 +22,14 @@ class BuildPathError(CondaStoreError): def symlink(source, target): - if os.path.islink(target): - os.unlink(target) - os.symlink(source, target) + # Multiple builds call this, so this lock avoids race conditions on unlink + # and symlink operations + with FileLock(f"{target}.lock"): + try: + os.unlink(target) + except FileNotFoundError: + pass + os.symlink(source, target) def chmod(directory, permissions): diff --git a/conda-store-server/conda_store_server/worker/tasks.py b/conda-store-server/conda_store_server/worker/tasks.py index 9d33e5e64..e87cc6a99 100644 --- a/conda-store-server/conda_store_server/worker/tasks.py +++ b/conda-store-server/conda_store_server/worker/tasks.py @@ -106,10 +106,15 @@ def task_update_storage_metrics(self): @shared_task(base=WorkerTask, name="task_cleanup_builds", bind=True) -def task_cleanup_builds(self, build_ids: typing.List[str] = None, reason: str = None): +def task_cleanup_builds( + self, + build_ids: typing.List[str] = None, + reason: str = None, + is_canceled: bool = False, +): conda_store = self.worker.conda_store with conda_store.session_factory() as db: - build_cleanup(db, conda_store, build_ids, reason) + build_cleanup(db, conda_store, build_ids, reason, is_canceled) """ diff --git a/conda-store-server/environment-dev.yaml b/conda-store-server/environment-dev.yaml index fe30479a0..b7dc836ad 100644 --- a/conda-store-server/environment-dev.yaml +++ b/conda-store-server/environment-dev.yaml @@ -12,6 +12,7 @@ dependencies: - conda-pack - conda-lock >=1.0.5 - conda-package-handling + - conda-package-streaming # web server - celery - flower diff --git a/conda-store-server/environment-macos-dev.yaml b/conda-store-server/environment-macos-dev.yaml index 6ece10f5d..3cab8855d 100644 --- a/conda-store-server/environment-macos-dev.yaml +++ b/conda-store-server/environment-macos-dev.yaml @@ -12,6 +12,7 @@ dependencies: - conda-lock >=1.0.5 - mamba - conda-package-handling + - conda-package-streaming # web server - celery - flower diff --git a/conda-store-server/environment-windows-dev.yaml b/conda-store-server/environment-windows-dev.yaml index 6ece10f5d..3cab8855d 100644 --- a/conda-store-server/environment-windows-dev.yaml +++ b/conda-store-server/environment-windows-dev.yaml @@ -12,6 +12,7 @@ dependencies: - conda-lock >=1.0.5 - mamba - conda-package-handling + - conda-package-streaming # web server - celery - flower diff --git a/conda-store-server/environment.yaml b/conda-store-server/environment.yaml index f3eaa0614..f3b8e40f9 100644 --- a/conda-store-server/environment.yaml +++ b/conda-store-server/environment.yaml @@ -11,6 +11,7 @@ dependencies: - conda-pack - conda-lock >=1.0.5 - conda-package-handling + - conda-package-streaming # web server - celery - flower diff --git a/conda-store-server/hatch_build.py b/conda-store-server/hatch_build.py index 48254bbfe..b4e55c75d 100644 --- a/conda-store-server/hatch_build.py +++ b/conda-store-server/hatch_build.py @@ -8,7 +8,7 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface -CONDA_STORE_UI_VERSION = "2024.1.1" +CONDA_STORE_UI_VERSION = "2024.3.1" CONDA_STORE_UI_URL = f"https://registry.npmjs.org/@conda-store/conda-store-ui/-/conda-store-ui-{CONDA_STORE_UI_VERSION}.tgz" CONDA_STORE_UI_FILES = [ "main.js", diff --git a/conda-store-server/pyproject.toml b/conda-store-server/pyproject.toml index 38cca0ed7..c5c3e6093 100644 --- a/conda-store-server/pyproject.toml +++ b/conda-store-server/pyproject.toml @@ -80,6 +80,8 @@ playwright-test = [ ] integration-test = ["pytest ../tests/test_api.py ../tests/test_metrics.py"] +user-journey-test = ["pytest -m user_journey"] + [tool.hatch.build.hooks.custom] [tool.hatch.build.targets.sdist.hooks.custom] @@ -101,3 +103,10 @@ ignore = [ [tool.check-wheel-contents] # ignore alembic migrations https://github.com/jwodder/check-wheel-contents?tab=readme-ov-file#w004--module-is-not-located-at-importable-path ignore = ["W004"] + +[tool.pytest.ini_options] +markers = [ + "playwright: mark a test as a playwright test", + "integration: mark a test as an integration test", + "user_journey: mark a test as a user journey test", +] diff --git a/conda-store-server/tests/conftest.py b/conda-store-server/tests/conftest.py index b2cfc9b47..32ed0bda5 100644 --- a/conda-store-server/tests/conftest.py +++ b/conda-store-server/tests/conftest.py @@ -1,5 +1,4 @@ import datetime -import os import pathlib import sys @@ -7,18 +6,32 @@ import yaml from fastapi.testclient import TestClient -from conda_store_server import action, api, app, dbutil, schema, storage, testing, utils # isort:skip +from conda_store_server import ( # isort:skip + action, + api, + app, + dbutil, + schema, + storage, + testing, + utils, +) + from conda_store_server.server import app as server_app # isort:skip @pytest.fixture def celery_config(tmp_path, conda_store): config = conda_store.celery_config - config["traitlets"] = {"CondaStore": { - "database_url": conda_store.database_url, - "store_directory": conda_store.store_directory, - }} - config["beat_schedule_filename"] = str(tmp_path / ".conda-store" / "celerybeat-schedule") + config["traitlets"] = { + "CondaStore": { + "database_url": conda_store.database_url, + "store_directory": conda_store.store_directory, + } + } + config["beat_schedule_filename"] = str( + tmp_path / ".conda-store" / "celerybeat-schedule" + ) return config @@ -38,7 +51,7 @@ def conda_store_config(tmp_path, request): CondaStore=dict( storage_class=storage.LocalStorage, store_directory=str(store_directory), - database_url=f"sqlite:///{filename}?check_same_thread=False" + database_url=f"sqlite:///{filename}?check_same_thread=False", ) ) diff --git a/conda-store-server/tests/test_actions.py b/conda-store-server/tests/test_actions.py index df5d91a65..02f710481 100644 --- a/conda-store-server/tests/test_actions.py +++ b/conda-store-server/tests/test_actions.py @@ -1,11 +1,9 @@ import asyncio import datetime -import os import pathlib import re import subprocess import sys -import tempfile import pytest import yarl @@ -20,7 +18,6 @@ utils, ) from conda_store_server.server.auth import DummyAuthentication -from fastapi import Request from fastapi.responses import RedirectResponse from traitlets import TraitError @@ -35,12 +32,20 @@ def test_function(context): context.run(["cmd", "/c", "echo subprocess"]) context.run("echo subprocess_stdout", shell=True) context.run("echo subprocess_stderr>&2", shell=True) - context.run("echo subprocess_stderr_no_redirect>&2", shell=True, redirect_stderr=False) + context.run( + "echo subprocess_stderr_no_redirect>&2", + shell=True, + redirect_stderr=False, + ) else: context.run(["echo", "subprocess"]) context.run("echo subprocess_stdout", shell=True) context.run("echo subprocess_stderr 1>&2", shell=True) - context.run("echo subprocess_stderr_no_redirect 1>&2", shell=True, redirect_stderr=False) + context.run( + "echo subprocess_stderr_no_redirect 1>&2", + shell=True, + redirect_stderr=False, + ) context.log.info("log") return pathlib.Path.cwd() @@ -85,9 +90,9 @@ def test_solve_lockfile_valid_conda_flags(conda_store, simple_specification): # Checks that conda_flags is used by conda-lock def test_solve_lockfile_invalid_conda_flags(conda_store, simple_specification): - with pytest.raises(Exception, match=( - r"Command.*--this-is-invalid.*returned non-zero exit status" - )): + with pytest.raises( + Exception, match=(r"Command.*--this-is-invalid.*returned non-zero exit status") + ): action.action_solve_lockfile( conda_command=conda_store.conda_command, specification=simple_specification, @@ -120,7 +125,9 @@ def test_solve_lockfile_multiple_platforms(conda_store, specification, request): "simple_specification_with_pip", ], ) -def test_generate_constructor_installer(conda_store, specification_name, request, tmp_path): +def test_generate_constructor_installer( + conda_store, specification_name, request, tmp_path +): specification = request.getfixturevalue(specification_name) installer_dir = tmp_path / "installer_dir" @@ -139,27 +146,27 @@ def test_generate_constructor_installer(conda_store, specification_name, request tmp_dir = tmp_path / "tmp" # Runs the installer - out_dir = pathlib.Path(tmp_dir) / 'out' - if sys.platform == 'win32': - subprocess.check_output([installer, '/S', f'/D={out_dir}']) + out_dir = pathlib.Path(tmp_dir) / "out" + if sys.platform == "win32": + subprocess.check_output([installer, "/S", f"/D={out_dir}"]) else: - subprocess.check_output([installer, '-b', '-p', str(out_dir)]) + subprocess.check_output([installer, "-b", "-p", str(out_dir)]) # Checks the output directory assert out_dir.exists() - lib_dir = out_dir / 'lib' - if specification_name == 'simple_specification': - if sys.platform == 'win32': - assert any(str(x).endswith('zlib.dll') for x in out_dir.iterdir()) - elif sys.platform == 'darwin': - assert any(str(x).endswith('libz.dylib') for x in lib_dir.iterdir()) + lib_dir = out_dir / "lib" + if specification_name == "simple_specification": + if sys.platform == "win32": + assert any(str(x).endswith("zlib.dll") for x in out_dir.iterdir()) + elif sys.platform == "darwin": + assert any(str(x).endswith("libz.dylib") for x in lib_dir.iterdir()) else: - assert any(str(x).endswith('libz.so') for x in lib_dir.iterdir()) + assert any(str(x).endswith("libz.so") for x in lib_dir.iterdir()) else: # Uses rglob to not depend on the version of the python # directory, which is where site-packages is located - flask = pathlib.Path('site-packages') / 'flask' - assert any(str(x).endswith(str(flask)) for x in out_dir.rglob('*')) + flask = pathlib.Path("site-packages") / "flask" + assert any(str(x).endswith(str(flask)) for x in out_dir.rglob("*")) def test_fetch_and_extract_conda_packages(tmp_path, simple_conda_lock): @@ -199,7 +206,7 @@ def test_generate_conda_export(conda_store, conda_prefix): ) # The env name won't be correct because conda only sets the env name when # an environment is in an envs dir. See the discussion on PR #549. - context.result['name'] = 'test-prefix' + context.result["name"] = "test-prefix" schema.CondaSpecification.parse_obj(context.result) @@ -215,9 +222,12 @@ def test_generate_conda_pack(tmp_path, conda_prefix): assert output_filename.exists() -@pytest.mark.xfail(reason=( - "Generating Docker images is currently not supported, see " - "https://github.com/conda-incubator/conda-store/issues/666")) +@pytest.mark.xfail( + reason=( + "Generating Docker images is currently not supported, see " + "https://github.com/conda-incubator/conda-store/issues/666" + ) +) def test_generate_conda_docker(conda_store, conda_prefix): action.action_generate_conda_docker( conda_prefix=conda_prefix, @@ -253,7 +263,9 @@ def test_remove_conda_prefix(tmp_path, simple_conda_lock): assert not conda_prefix.exists() -@pytest.mark.skipif(sys.platform == "win32", reason="permissions are not supported on Windows") +@pytest.mark.skipif( + sys.platform == "win32", reason="permissions are not supported on Windows" +) def test_set_conda_prefix_permissions(tmp_path, conda_store, simple_conda_lock): conda_prefix = tmp_path / "test" @@ -322,14 +334,23 @@ def test_add_lockfile_packages( ], ) def test_api_get_build_lockfile( - request, conda_store, db, simple_specification_with_pip, conda_prefix, is_legacy_build, build_key_version + request, + conda_store, + db, + simple_specification_with_pip, + conda_prefix, + is_legacy_build, + build_key_version, ): # sets build_key_version if build_key_version == 0: # invalid - with pytest.raises(TraitError, match=( - r"c.CondaStore.build_key_version: invalid build key version: 0, " - r"expected: \(1, 2\)" - )): + with pytest.raises( + TraitError, + match=( + r"c.CondaStore.build_key_version: invalid build key version: 0, " + r"expected: \(1, 2\)" + ), + ): conda_store.build_key_version = build_key_version return # invalid, nothing more to test conda_store.build_key_version = build_key_version @@ -389,7 +410,8 @@ def authorize_request(self, *args, **kwargs): namespace=namespace, environment_name=environment.name, build_id=build_id, - )) + ) + ) if key == "": # legacy build: returns pinned package list @@ -404,6 +426,7 @@ def authorize_request(self, *args, **kwargs): # new build: redirects to lockfile generated by conda-lock def lockfile_url(build_key): return f"lockfile/{build_key}.yml" + if build_key_version == 1: build_key = ( "c7afdeffbe2bda7d16ca69beecc8bebeb29280a95d4f3ed92849e4047710923b-" @@ -414,13 +437,13 @@ def lockfile_url(build_key): else: raise ValueError(f"unexpected build_key_version: {build_key_version}") assert type(res) is RedirectResponse - assert key == res.headers['location'] + assert key == res.headers["location"] assert build.build_key == build_key assert BuildKey.get_build_key(build) == build_key assert build.parse_build_key(build_key) == 12345678 assert BuildKey.parse_build_key(build_key) == 12345678 assert lockfile_url(build_key) == build.conda_lock_key - assert lockfile_url(build_key) == res.headers['location'] + assert lockfile_url(build_key) == res.headers["location"] assert res.status_code == 307 @@ -464,7 +487,8 @@ def authorize_request(self, *args, **kwargs): conda_store=conda_store, auth=auth, build_id=build_id, - )) + ) + ) # redirects to installer def installer_url(build_key): @@ -472,7 +496,7 @@ def installer_url(build_key): return f"installer/{build_key}.{ext}" assert type(res) is RedirectResponse - assert build.constructor_installer_key == res.headers['location'] + assert build.constructor_installer_key == res.headers["location"] assert installer_url(build.build_key) == build.constructor_installer_key assert res.status_code == 307 diff --git a/conda-store-server/tests/test_auth.py b/conda-store-server/tests/test_auth.py index db39ab724..99145d5ff 100644 --- a/conda-store-server/tests/test_auth.py +++ b/conda-store-server/tests/test_auth.py @@ -129,7 +129,6 @@ def test_expired_token(): ], ) def test_authorization(conda_store, entity_bindings, arn, permissions, authorized): - authorization = RBACAuthorizationBackend( authentication_db=conda_store.session_factory ) @@ -187,7 +186,14 @@ def test_authorization(conda_store, entity_bindings, arn, permissions, authorize ], ) @pytest.mark.parametrize("role_mappings_version", [1, 2]) -def test_end_to_end_auth_flow_v1(conda_store_server, testclient, authenticate, role_mappings_version, role, permissions): +def test_end_to_end_auth_flow_v1( + conda_store_server, + testclient, + authenticate, + role_mappings_version, + role, + permissions, +): # Configures authentication namespace = f"this-{uuid.uuid4()}" other_namespace = f"other-{uuid.uuid4()}" @@ -209,40 +215,41 @@ def test_end_to_end_auth_flow_v1(conda_store_server, testclient, authenticate, r authorization = RBACAuthorizationBackend( authentication_db=conda_store.session_factory, - role_mappings_version = role_mappings_version, + role_mappings_version=role_mappings_version, ) + def authorize(): return authorization.authorize( - AuthenticationToken( - primary_namespace=token_model.primary_namespace, - role_bindings=token_model.role_bindings, - ), - f"{other_namespace}/example-name", - permissions, - ) + AuthenticationToken( + primary_namespace=token_model.primary_namespace, + role_bindings=token_model.role_bindings, + ), + f"{other_namespace}/example-name", + permissions, + ) + # No default roles assert authorize() is False # Creates new namespaces for n in (namespace, other_namespace): - response = testclient.post( - f"api/v1/namespace/{n}" - ) + response = testclient.post(f"api/v1/namespace/{n}") response.raise_for_status() # Deletes roles to start with a clean state response = testclient.put( - f"api/v1/namespace/{namespace}", - json={"role_mappings": {}} + f"api/v1/namespace/{namespace}", json={"role_mappings": {}} ) response.raise_for_status() # Creates role for 'namespace' with access to 'other_namespace' response = testclient.put( f"api/v1/namespace/{namespace}", - json={"role_mappings": { - f"{other_namespace}/ex*-name": [role], - }} + json={ + "role_mappings": { + f"{other_namespace}/ex*-name": [role], + } + }, ) response.raise_for_status() @@ -254,8 +261,7 @@ def authorize(): # Deletes created roles response = testclient.put( - f"api/v1/namespace/{namespace}", - json={"role_mappings": {}} + f"api/v1/namespace/{namespace}", json={"role_mappings": {}} ) response.raise_for_status() @@ -273,7 +279,14 @@ def authorize(): ], ) @pytest.mark.parametrize("role_mappings_version", [1, 2]) -def test_end_to_end_auth_flow_v2(conda_store_server, testclient, authenticate, role_mappings_version, role, permissions): +def test_end_to_end_auth_flow_v2( + conda_store_server, + testclient, + authenticate, + role_mappings_version, + role, + permissions, +): # Configures authentication namespace = f"this-{uuid.uuid4()}" other_namespace = f"other-{uuid.uuid4()}" @@ -295,8 +308,9 @@ def test_end_to_end_auth_flow_v2(conda_store_server, testclient, authenticate, r authorization = RBACAuthorizationBackend( authentication_db=conda_store.session_factory, - role_mappings_version = role_mappings_version, + role_mappings_version=role_mappings_version, ) + def authorize(): return authorization.authorize( AuthenticationToken( @@ -306,29 +320,23 @@ def authorize(): f"{other_namespace}/example-name", permissions, ) + # No default roles assert authorize() is False # Creates new namespaces for n in (namespace, other_namespace): - response = testclient.post( - f"api/v1/namespace/{n}" - ) + response = testclient.post(f"api/v1/namespace/{n}") response.raise_for_status() # Deletes roles to start with a clean state - response = testclient.delete( - f"api/v1/namespace/{other_namespace}/roles" - ) + response = testclient.delete(f"api/v1/namespace/{other_namespace}/roles") response.raise_for_status() # Creates role for 'namespace' with access to 'other_namespace' response = testclient.post( f"api/v1/namespace/{other_namespace}/role", - json={ - "other_namespace": namespace, - "role": role - }, + json={"other_namespace": namespace, "role": role}, ) response.raise_for_status() @@ -339,9 +347,7 @@ def authorize(): assert authorize() is False # Deletes created roles - response = testclient.delete( - f"api/v1/namespace/{other_namespace}/roles" - ) + response = testclient.delete(f"api/v1/namespace/{other_namespace}/roles") response.raise_for_status() # Should fail again diff --git a/conda-store-server/tests/test_db_api.py b/conda-store-server/tests/test_db_api.py index 4c9e229b5..25b86cdf8 100644 --- a/conda-store-server/tests/test_db_api.py +++ b/conda-store-server/tests/test_db_api.py @@ -98,29 +98,44 @@ def test_namespace_role_mapping_v2(db, editor_role): assert len(api.list_namespaces(db).all()) == 4 # Creates role mappings - api.create_namespace_role(db, name=namespace_name, other=other_namespace_name1, role="admin") - api.create_namespace_role(db, name=namespace_name, other=other_namespace_name2, role="admin") - api.create_namespace_role(db, name=namespace_name, other=other_namespace_name3, role="viewer") + api.create_namespace_role( + db, name=namespace_name, other=other_namespace_name1, role="admin" + ) + api.create_namespace_role( + db, name=namespace_name, other=other_namespace_name2, role="admin" + ) + api.create_namespace_role( + db, name=namespace_name, other=other_namespace_name3, role="viewer" + ) db.commit() # Attempts to create a role mapping with an invalid role with pytest.raises(ValueError, match=r"invalid role=invalid-role"): - api.create_namespace_role(db, name=namespace_name, other=other_namespace_name3, role="invalid-role") + api.create_namespace_role( + db, name=namespace_name, other=other_namespace_name3, role="invalid-role" + ) db.commit() # Attempts to create a role mapping violating the uniqueness constraint with pytest.raises( - Exception, - match=(r"UNIQUE constraint failed: " - r"namespace_role_mapping_v2.namespace_id, " - r"namespace_role_mapping_v2.other_namespace_id")): + Exception, + match=( + r"UNIQUE constraint failed: " + r"namespace_role_mapping_v2.namespace_id, " + r"namespace_role_mapping_v2.other_namespace_id" + ), + ): # Runs in a nested transaction since a constraint violation will cause a rollback with db.begin_nested(): - api.create_namespace_role(db, name=namespace_name, other=other_namespace_name2, role=editor_role) + api.create_namespace_role( + db, name=namespace_name, other=other_namespace_name2, role=editor_role + ) db.commit() # Updates a role mapping - api.update_namespace_role(db, name=namespace_name, other=other_namespace_name2, role=editor_role) + api.update_namespace_role( + db, name=namespace_name, other=other_namespace_name2, role=editor_role + ) db.commit() # Gets all role mappings @@ -131,17 +146,17 @@ def test_namespace_role_mapping_v2(db, editor_role): assert roles[0].id == 1 assert roles[0].namespace == namespace_name assert roles[0].other_namespace == other_namespace_name1 - assert roles[0].role == 'admin' + assert roles[0].role == "admin" assert roles[1].id == 2 assert roles[1].namespace == namespace_name assert roles[1].other_namespace == other_namespace_name2 - assert roles[1].role == 'developer' # always developer in the DB + assert roles[1].role == "developer" # always developer in the DB assert roles[2].id == 3 assert roles[2].namespace == namespace_name assert roles[2].other_namespace == other_namespace_name3 - assert roles[2].role == 'viewer' + assert roles[2].role == "viewer" # Gets other role mappings roles = api.get_other_namespace_roles(db, other_namespace_name1) @@ -163,12 +178,12 @@ def test_namespace_role_mapping_v2(db, editor_role): assert roles[0].id == 1 assert roles[0].namespace == namespace_name assert roles[0].other_namespace == other_namespace_name1 - assert roles[0].role == 'admin' + assert roles[0].role == "admin" assert roles[1].id == 3 assert roles[1].namespace == namespace_name assert roles[1].other_namespace == other_namespace_name3 - assert roles[1].role == 'viewer' + assert roles[1].role == "viewer" # Deletes all role mappings api.delete_namespace_roles(db, name=namespace_name) @@ -247,10 +262,12 @@ def test_get_set_keyvaluestore(db): def test_build_path_too_long(db, conda_store, simple_specification): - conda_store.store_directory = 'A' * 800 + conda_store.store_directory = "A" * 800 build_id = conda_store.register_environment( db, specification=simple_specification, namespace="pytest" ) build = api.get_build(db, build_id=build_id) - with pytest.raises(BuildPathError, match=r"build_path too long: must be <= 255 characters"): + with pytest.raises( + BuildPathError, match=r"build_path too long: must be <= 255 characters" + ): build.build_path(conda_store) diff --git a/conda-store-server/tests/test_server.py b/conda-store-server/tests/test_server.py index fb262fc75..4624f3650 100644 --- a/conda-store-server/tests/test_server.py +++ b/conda-store-server/tests/test_server.py @@ -26,11 +26,13 @@ def test_api_permissions_unauth(testclient): assert r.data.authenticated is False assert r.data.primary_namespace == "default" assert r.data.entity_permissions == { - "default/*": sorted([ - schema.Permissions.ENVIRONMENT_READ.value, - schema.Permissions.NAMESPACE_READ.value, - schema.Permissions.NAMESPACE_ROLE_MAPPING_READ.value, - ]) + "default/*": sorted( + [ + schema.Permissions.ENVIRONMENT_READ.value, + schema.Permissions.NAMESPACE_READ.value, + schema.Permissions.NAMESPACE_ROLE_MAPPING_READ.value, + ] + ) } @@ -357,9 +359,11 @@ def test_create_specification_unauth(testclient): 256, ], ) -def test_create_specification_auth_env_name_too_long(testclient, celery_worker, authenticate, size): +def test_create_specification_auth_env_name_too_long( + testclient, celery_worker, authenticate, size +): namespace = "default" - environment_name = 'A' * size + environment_name = "A" * size response = testclient.post( "api/v1/specification", @@ -395,13 +399,14 @@ def test_create_specification_auth_env_name_too_long(testclient, celery_worker, # If we're here, the task didn't update the status on failure if not is_updated: - assert False, f"failed to update status" + assert False, "failed to update status" @pytest.fixture def win_extended_length_prefix(request): # Overrides the attribute before other fixtures are called from conda_store_server.app import CondaStore + assert type(CondaStore.win_extended_length_prefix) is traitlets.Bool old_prefix = CondaStore.win_extended_length_prefix CondaStore.win_extended_length_prefix = request.param @@ -410,11 +415,13 @@ def win_extended_length_prefix(request): @pytest.mark.skipif(sys.platform != "win32", reason="tests a Windows issue") -@pytest.mark.parametrize('win_extended_length_prefix', [True, False], indirect=True) +@pytest.mark.parametrize("win_extended_length_prefix", [True, False], indirect=True) @pytest.mark.extended_prefix -def test_create_specification_auth_extended_prefix(win_extended_length_prefix, testclient, celery_worker, authenticate): +def test_create_specification_auth_extended_prefix( + win_extended_length_prefix, testclient, celery_worker, authenticate +): # Adds padding to cause an error if the extended prefix is not enabled - namespace = "default" + 'A' * 10 + namespace = "default" + "A" * 10 environment_name = "pytest" # The debugpy 1.8.0 package was deliberately chosen because it has long @@ -424,14 +431,16 @@ def test_create_specification_auth_extended_prefix(win_extended_length_prefix, t "api/v1/specification", json={ "namespace": namespace, - "specification": json.dumps({ - "name": environment_name, - "channels": ["conda-forge"], - "dependencies": ["debugpy==1.8.0"], - "variables": None, - "prefix": None, - "description": "test" - }), + "specification": json.dumps( + { + "name": environment_name, + "channels": ["conda-forge"], + "dependencies": ["debugpy==1.8.0"], + "variables": None, + "prefix": None, + "description": "test", + } + ), }, timeout=30, ) @@ -462,7 +471,9 @@ def test_create_specification_auth_extended_prefix(win_extended_length_prefix, t assert r.data.status == "FAILED" response = testclient.get(f"api/v1/build/{build_id}/logs", timeout=30) response.raise_for_status() - assert "[WinError 206] The filename or extension is too long" in response.text + assert ( + "[WinError 206] The filename or extension is too long" in response.text + ) is_updated = True break diff --git a/conda-store-server/tests/test_usage.py b/conda-store-server/tests/test_usage.py index fcf3990a8..98833938c 100644 --- a/conda-store-server/tests/test_usage.py +++ b/conda-store-server/tests/test_usage.py @@ -2,6 +2,7 @@ # TODO: Add tests for the other functions in utils.py + def test_disk_usage(tmp_path): test_dir = tmp_path / "test_dir" test_dir.mkdir() @@ -11,9 +12,9 @@ def test_disk_usage(tmp_path): assert abs(dir_size - int(disk_usage(test_dir))) <= 1000 test_file = test_dir / "test_file" - test_file.write_text("a"*1000) + test_file.write_text("a" * 1000) test_file2 = test_dir / "test_file2" - test_file2.write_text("b"*1000) + test_file2.write_text("b" * 1000) # Test hard links test_file_hardlink = test_dir / "test_file_hardlink" test_file_hardlink.hardlink_to(test_file) diff --git a/conda-store-server/tests/user_journeys/README.md b/conda-store-server/tests/user_journeys/README.md new file mode 100644 index 000000000..7e0dbceb6 --- /dev/null +++ b/conda-store-server/tests/user_journeys/README.md @@ -0,0 +1,83 @@ +# User Journey Tests + +This repository contains user journey tests for the API. User journey tests +are end-to-end tests that simulate real user scenarios to ensure the API +functions correctly in different scenarios. + +These tests will use the high-privileged token to create a randomly-named +namespace (using a UUID) to prevent conflicts with existing namespaces. At the +end of the test, it will delete any environments created and then delete the +namespace. + +## Prerequisites + +These tests are blackbox tests and need a running server to test against. This +can be a local conda-store instance started using docker compose, or a remote +instance. You will need the base url of the server and a token for an admin +user to run these tests. + +## Setup + +### Local setup + +To run locally using docker compose all you need to do is start conda-store. + +From the project base, run `docker compose up`. + +### Remote setup + +To run these tests against a remote server, you need to set 2 environment +variables: + +1. `CONDA_STORE_BASE_URL` - this is the base url of your conda-store-server. + + For example, if you access your conda-store-server at `https://example.com`, + you would run `export CONDA_STORE_BASE_URL='https://example.com'`. + + **Do not include the `/conda-store/` suffix.** + + Do include the port if needed. + For example: `export CONDA_STORE_BASE_URL='http://localhost:8080'`. + +2. `CONDA_STORE_TOKEN` - this should be the token of an admin user. + + This token will let the tests create the tokens, permissions, namespaces, + and environments needed for these tests to run successfully. + + To generate a token, while logged in as a high-privileged user, go to + `https:///conda-store/admin/user/` and click on + `Create token`. + + Copy that token value and export it: + `export CONDA_STORE_TOKEN='my_token_value'`. + +## Running the tests + +To run the tests, run `pytest -m user_journey` from the `conda-store-server` +directory. + +## Current scenarios tested + +* An admin user can create a simple environment in a shared namespace and, once + the environment is built, can delete the environment. + +## Planned scenarios to be implemented + +* An admin can create a complex environment in a shared namespace and, once the + environment is built, can delete the environment + +* A developer can create a simple environment in a shared namespace and, once + the environment is built, can delete the environment + +* A developer can create a complex environment in a shared namespace and, once + the environment is built, can delete the environment + +* A developer can create an environment in a shared namespace and, once the + environment is built, can modify the environment, then can mark the first + build as active + +* A developer can create a simple environment in a shared namespace and, once + the environment is built, can get the lockfile for the environment + +* A developer can create a failing environment in a shared namespace and, once + the environment has failed, can get the logs for the failed build. diff --git a/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml b/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml new file mode 100644 index 000000000..faa1ab42e --- /dev/null +++ b/conda-store-server/tests/user_journeys/test_data/simple_environment.yaml @@ -0,0 +1,6 @@ +name: simple-test-environment +channels: + - conda-forge +dependencies: + - python ==3.10 + - fastapi diff --git a/conda-store-server/tests/user_journeys/test_user_journeys.py b/conda-store-server/tests/user_journeys/test_user_journeys.py new file mode 100644 index 000000000..1d4ee7d23 --- /dev/null +++ b/conda-store-server/tests/user_journeys/test_user_journeys.py @@ -0,0 +1,43 @@ +"""User journey tests for the API.""" +import os + +import pytest +import utils.api_utils as utils + + +@pytest.fixture(scope="session") +def base_url() -> str: + """Get the base URL for the API.""" + base = os.getenv("CONDA_STORE_BASE_URL", "http://localhost:8080") + return f"{base}/conda-store" + + +@pytest.fixture(scope="session") +def token(base_url) -> str: + """Get the token for the API.""" + return os.getenv("CONDA_STORE_TOKEN", "") + + +@pytest.mark.user_journey +@pytest.mark.parametrize( + "specification_path", + [ + ("tests/user_journeys/test_data/simple_environment.yaml"), + ], +) +def test_admin_user_can_create_environment( + base_url: str, token: str, specification_path: str +) -> None: + """Test that an admin user can create an environment.""" + namespace = utils.API.gen_random_namespace() + api = utils.API(base_url=base_url, token=token) + api.create_namespace(namespace) + response = api.create_environment(namespace, specification_path) + data = response.json()["data"] + assert "build_id" in data + build_id = data["build_id"] + assert build_id is not None + build = api.wait_for_successful_build(build_id) + environment_name = build.json()["data"]["specification"]["name"] + api.delete_environment(namespace, environment_name) + api.delete_namespace(namespace) diff --git a/conda-store-server/tests/user_journeys/utils/__init__.py b/conda-store-server/tests/user_journeys/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conda-store-server/tests/user_journeys/utils/api_utils.py b/conda-store-server/tests/user_journeys/utils/api_utils.py new file mode 100644 index 000000000..4fcef9035 --- /dev/null +++ b/conda-store-server/tests/user_journeys/utils/api_utils.py @@ -0,0 +1,135 @@ +"""Helper functions for user journeys.""" +import time +import uuid +from enum import Enum + +import requests +import utils.time_utils as time_utils + +TIMEOUT = 10 + + +class BuildStatus(Enum): + """Enum for API build status.""" + + QUEUED = "QUEUED" + BUILDING = "BUILDING" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELED = "CANCELED" + + +class API: + """ + Helper class for making requests to the API. + These methods are used to build tests for user journeys + """ + + def __init__( + self, + base_url: str, + token: str = "", + username: str = "username", + password: str = "password", + ) -> None: + self.base_url = base_url + self.token = token + if not token: + # Log in if no token is provided to set the token + self._login(username, password) + + def _make_request( + self, + endpoint: str, + method: str = "GET", + json_data: dict = None, + headers: dict = None, + timeout: int = TIMEOUT, + ) -> requests.Response: + """Make a request to the API.""" + url = f"{self.base_url}/{endpoint}" + headers = headers or {} + headers["Authorization"] = f"Bearer {self.token}" + response = requests.request( + method, url, json=json_data, headers=headers, timeout=timeout + ) + response.raise_for_status() + return response + + def _login(self, username: str, password: str) -> None: + """Log in to the API and set an access token.""" + json_data = {"username": username, "password": password} + response = requests.post( + f"{self.base_url}/login", json=json_data, timeout=TIMEOUT + ) + cookies = response.cookies.get_dict() + token_response = requests.post( + f"{self.base_url}/api/v1/token", cookies=cookies, timeout=TIMEOUT + ) + data = token_response.json() + self.token = data["data"]["token"] + + def create_namespace(self, namespace: str) -> requests.Response: + """Create a namespace.""" + return self._make_request(f"api/v1/namespace/{namespace}", method="POST") + + def create_token( + self, namespace: str, role: str, default_namespace: str = "default" + ) -> requests.Response: + """Create a token with a specified role in a specified namespace.""" + json_data = { + "primary_namespace": default_namespace, + "expiration": time_utils.get_iso8601_time(1), + "role_bindings": {f"{namespace}/*": [role]}, + } + return self._make_request("api/v1/token", method="POST", json_data=json_data) + + def create_environment( + self, namespace: str, specification_path: str + ) -> requests.Response: + """ + Create an environment. + The environment specification is read + from a conda environment.yaml file. + """ + with open(specification_path, "r", encoding="utf-8") as file: + specification_content = file.read() + + json_data = {"namespace": namespace, "specification": specification_content} + + return self._make_request( + "api/v1/specification", method="POST", json_data=json_data + ) + + def wait_for_successful_build( + self, build_id: str, max_iterations: int = 100, sleep_time: int = 5 + ) -> requests.Response: + """Wait for a build to complete.""" + status = BuildStatus.QUEUED.value + iterations = 0 + while status != BuildStatus.COMPLETED.value: + if iterations > max_iterations: + raise TimeoutError("Timed out waiting for build") + response = self._make_request(f"api/v1/build/{build_id}", method="GET") + status = response.json()["data"]["status"] + assert status != BuildStatus.FAILED.value, "Build failed" + iterations += 1 + time.sleep(sleep_time) + return response + + def delete_environment( + self, namespace: str, environment_name: str + ) -> requests.Response: + """Delete an environment.""" + return self._make_request( + f"api/v1/environment/{namespace}/{environment_name}", method="DELETE" + ) + + def delete_namespace(self, namespace: str) -> requests.Response: + """Delete a namespace.""" + return self._make_request(f"api/v1/namespace/{namespace}", method="DELETE") + + @staticmethod + def gen_random_namespace() -> str: + """Generate a random namespace.""" + return uuid.uuid4().hex diff --git a/conda-store-server/tests/user_journeys/utils/time_utils.py b/conda-store-server/tests/user_journeys/utils/time_utils.py new file mode 100644 index 000000000..4b94b2302 --- /dev/null +++ b/conda-store-server/tests/user_journeys/utils/time_utils.py @@ -0,0 +1,20 @@ +from datetime import datetime, timedelta, timezone + + +def get_current_time() -> datetime: + """Get the current time.""" + return datetime.now(timezone.utc) + + +def get_time_in_future(hours: int) -> datetime: + """Get the time in the future.""" + current_time = get_current_time() + future_time = current_time + timedelta(hours=hours) + return future_time + + +def get_iso8601_time(hours: int) -> str: + """Get the time in the future in ISO 8601 format.""" + future_time = get_time_in_future(hours) + iso_format = future_time.isoformat() + return iso_format diff --git a/conda-store/conda_store/__init__.py b/conda-store/conda_store/__init__.py index 754adf609..1e9d9afbc 100644 --- a/conda-store/conda_store/__init__.py +++ b/conda-store/conda_store/__init__.py @@ -1 +1 @@ -__version__ = "2024.1.1" +__version__ = "2024.3.1" diff --git a/docusaurus-docs/conda-store-ui/images/versions-view.png b/docusaurus-docs/conda-store-ui/images/versions-view.png deleted file mode 100644 index f2610a4ce..000000000 Binary files a/docusaurus-docs/conda-store-ui/images/versions-view.png and /dev/null differ diff --git a/docusaurus-docs/conda-store-ui/tutorials/create-envs.md b/docusaurus-docs/conda-store-ui/tutorials/create-envs.md index 0636ebb06..d2abf07a1 100644 --- a/docusaurus-docs/conda-store-ui/tutorials/create-envs.md +++ b/docusaurus-docs/conda-store-ui/tutorials/create-envs.md @@ -83,6 +83,22 @@ To install packages published only on [PyPI][pypi] using [`pip`][pip], include a --> +### Set environment variables + +:::note +This feature is available *after* conda-store-ui version 2024.1.1. + +Currently, only the `CONDA_OVERRIDE_CUDA` environment variable can be specified, which allows setting the CUDA version for building packages with GPU support. +Learn more in the [conda documentation][conda-docs-override-packages] +::: + +You can set environment variables in the YAML editor with the following syntax: + +```yaml +variables: + CONDA_OVERRIDE_CUDA: '12.0' +``` + ## Trigger environment creation Once the name, description, required packages, and channels are specified, click on the "Create" button at the bottom of the screen to trigger environment creation: @@ -100,3 +116,4 @@ The "Status" will change to "Status: Completed in ... min" once the environment [pypi]: https://pypi.org [pip]: https://pip.pypa.io/en/stable/installation/ +[conda-docs-override-packages]: https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-virtual.html#overriding-detected-packages diff --git a/docusaurus-docs/conda-store-ui/tutorials/version-control.md b/docusaurus-docs/conda-store-ui/tutorials/version-control.md index e45d67893..05053f945 100644 --- a/docusaurus-docs/conda-store-ui/tutorials/version-control.md +++ b/docusaurus-docs/conda-store-ui/tutorials/version-control.md @@ -15,7 +15,7 @@ You can view other versions of the environment and set them as your active envir 2. In the "Environment Metadata" section, under "Builds:", click on the downward arrow to open the environment dropdown. This shows the all versions of your environment with build date and status. The "Active" one is your currently active environment. The "Available" environments are successfully built environments that can be activated if needed. 3. In the dropdown, click on the build you wish to view. The page will be refreshed to display your selected environment. -![](../images/versions-view.png) +![](../images/version-select.png) ## Activate a version diff --git a/docusaurus-docs/docusaurus.config.js b/docusaurus-docs/docusaurus.config.js index e1e8cace0..00800a4e3 100644 --- a/docusaurus-docs/docusaurus.config.js +++ b/docusaurus-docs/docusaurus.config.js @@ -183,6 +183,11 @@ const config = { '⚠️ We are in the process of revamping our docs, some pages may be incomplete or inaccurate. ⚠️', isCloseable: false, }, + docs: { + sidebar: { + hideable: true, + }, + }, }), }; diff --git a/docusaurus-docs/jupyterlab-conda-store/images/conda-store-menu-item.png b/docusaurus-docs/jupyterlab-conda-store/images/conda-store-menu-item.png new file mode 100644 index 000000000..4ce4c05aa Binary files /dev/null and b/docusaurus-docs/jupyterlab-conda-store/images/conda-store-menu-item.png differ diff --git a/docusaurus-docs/jupyterlab-conda-store/install-extension.md b/docusaurus-docs/jupyterlab-conda-store/install-extension.md deleted file mode 100644 index 06db94012..000000000 --- a/docusaurus-docs/jupyterlab-conda-store/install-extension.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -sidebar_position: 2 -description: Install jupyterlab-conda-store ---- - -# Install JupyterLab extension diff --git a/docusaurus-docs/jupyterlab-conda-store/introduction.md b/docusaurus-docs/jupyterlab-conda-store/introduction.md deleted file mode 100644 index 709c4f44a..000000000 --- a/docusaurus-docs/jupyterlab-conda-store/introduction.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -sidebar_position: 1 -title: Introduction -description: Introduction to JupyterLab Extension. ---- - -# conda-store JupyterLab extension diff --git a/docusaurus-docs/jupyterlab-conda-store/introduction.mdx b/docusaurus-docs/jupyterlab-conda-store/introduction.mdx new file mode 100644 index 000000000..bcbb26cd2 --- /dev/null +++ b/docusaurus-docs/jupyterlab-conda-store/introduction.mdx @@ -0,0 +1,73 @@ +--- +title: Introduction +description: Introduction to JupyterLab Extension. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# conda-store JupyterLab extension + +An extension to use the [conda-store UI][conda-store-ui] - a React-based frontend for conda-store, within JupyterLab. + +## Install 📦 + +1. Pre-requisites: `conda-store-server`, JupyterLab >= 3.0 and <= 4.0, and Python >= 3.8 installed. + +2. Install the extension: + + + + + +```bash +conda install -c conda-forge jupyter-lab-conda-store +``` + + + + + +```bash +pip install jupyterlab-conda-store +``` + + + + + +3. Start JupyterLab: + +```bash +jupyter lab +``` + +4. (Optional) Uninstall the extension + + + + +```bash +conda uninstall jupyter-lab-conda-store +``` + + + +```bash +pip uninstall jupyterlab-conda-store +``` + + + +## Usage + +In the JupyterLab window, click on the `conda-store` menu bar item to open the UI in a new window within JupyterLab: + +![JupyterLab window's menu bar with `conda-store` at the end of the list containing the `Conda Store Package Manager` option](./images/conda-store-menu-item.png) + +Learn to use the interface with [conda-store UI tutorials][cs-ui-tutorials]. + + + +[conda-store-ui]: /conda-store-ui/introduction +[cs-ui-tutorials]: /conda-store-ui/tutorials diff --git a/tests/conftest.py b/tests/conftest.py index 5fbb76aa0..aa3135329 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,14 @@ import os import sys - -sys.path.append(os.path.join(os.getcwd(), "conda-store-server")) - from urllib.parse import urljoin import pytest from requests import Session -CONDA_STORE_SERVER_PORT = os.environ.get( - "CONDA_STORE_SERVER_PORT", f"8080" -) +sys.path.append(os.path.join(os.getcwd(), "conda-store-server")) + + +CONDA_STORE_SERVER_PORT = os.environ.get("CONDA_STORE_SERVER_PORT", "8080") CONDA_STORE_BASE_URL = os.environ.get( "CONDA_STORE_BASE_URL", f"http://localhost:{CONDA_STORE_SERVER_PORT}/conda-store/" ) @@ -49,6 +47,7 @@ def testclient(): session = CondaStoreSession(CONDA_STORE_BASE_URL) yield session + @pytest.fixture def server_port(): return CONDA_STORE_SERVER_PORT diff --git a/tests/test_api.py b/tests/test_api.py index dac1f4b88..9583249b1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,18 +9,19 @@ """ import asyncio +import collections +import datetime import json +import statistics import time import uuid from functools import partial -from typing import List import aiohttp import conda_store_server import pytest import requests from conda_store_server import schema -from pydantic import parse_obj_as from .conftest import CONDA_STORE_BASE_URL @@ -77,14 +78,16 @@ def test_api_permissions_unauth(testclient): r = schema.APIGetPermission.parse_obj(response.json()) assert r.status == schema.APIStatus.OK - assert r.data.authenticated == False + assert r.data.authenticated is False assert r.data.primary_namespace == "default" assert r.data.entity_permissions == { - "default/*": sorted([ - schema.Permissions.ENVIRONMENT_READ.value, - schema.Permissions.NAMESPACE_READ.value, - schema.Permissions.NAMESPACE_ROLE_MAPPING_READ.value, - ]) + "default/*": sorted( + [ + schema.Permissions.ENVIRONMENT_READ.value, + schema.Permissions.NAMESPACE_READ.value, + schema.Permissions.NAMESPACE_ROLE_MAPPING_READ.value, + ] + ) } @@ -95,7 +98,7 @@ def test_api_permissions_auth(testclient): r = schema.APIGetPermission.parse_obj(response.json()) assert r.status == schema.APIStatus.OK - assert r.data.authenticated == True + assert r.data.authenticated is True assert r.data.primary_namespace == "username" assert r.data.entity_permissions == { "*/*": sorted( @@ -467,6 +470,177 @@ def test_create_specification_auth(testclient): assert r.data.namespace.name == namespace +def test_create_specification_parallel_auth(testclient): + namespace = "default" + environment_name = f"pytest-{uuid.uuid4()}" + + # Builds different versions to avoid caching + versions = [ + "6.2.0", + "6.2.1", + "6.2.2", + "6.2.3", + "6.2.4", + "6.2.5", + "7.1.1", + "7.1.2", + "7.3.1", + "7.4.0", + ] + num_builds = len(versions) + limit_seconds = 60 * 15 + build_ids = collections.deque([]) + + # Spins up 'num_builds' builds and adds them to 'build_ids' + for version in versions: + testclient.login() + response = testclient.post( + "api/v1/specification", + json={ + "namespace": namespace, + "specification": json.dumps( + { + "name": environment_name, + "channels": ["main"], + "dependencies": [f"pytest={version}"], + } + ), + }, + timeout=10, + ) + response.raise_for_status() + r = schema.APIPostSpecification.parse_obj(response.json()) + assert r.status == schema.APIStatus.OK + build_ids.append(r.data.build_id) + + # How long it takes to do a single build + build_deltas = [] + + # Checks whether the builds are done (in the order they were scheduled in) + start = datetime.datetime.now() + prev = None + prev_builds = num_builds + while True: + # Prints the current build ids in the queue. Visually, if the server is + # configured to run N jobs in parallel, the queue should have N jobs + # less almost instantly once the first batch is done processing. After + # that, the queue should keep shrinking at a steady pace after each of + # the workers is done + print("build_ids", build_ids) + + # Checks whether the time limit is reached + now = datetime.datetime.now() + if (now - start).total_seconds() > limit_seconds: + break + + # Measures how long it takes to do a single build + if len(build_ids) < prev_builds: + if prev is not None: + build_delta = (now - prev).total_seconds() + print("build_delta", build_delta) + build_deltas.append(build_delta) + prev_builds = len(build_ids) + prev = now + + # Gets the oldest build in the queue as it's the one that's most likely + # to be done + try: + build_id = build_ids.popleft() + except IndexError: + break + + # Checks the status + response = testclient.get(f"api/v1/build/{build_id}", timeout=10) + response.raise_for_status() + r = schema.APIGetBuild.parse_obj(response.json()) + assert r.status == schema.APIStatus.OK + assert r.data.specification.name == environment_name + + # Gets build logs + def get_logs(): + response = testclient.get(f"api/v1/build/{build_id}/logs", timeout=10) + response.raise_for_status() + return response.text + + # Exits immediately on failure + assert r.data.status != "FAILED", get_logs() + + # If not done, adds the id back to the end of the queue + if r.data.status != "COMPLETED": + build_ids.append(build_id) + + # Adds a small delay to avoid making too many requests too fast. The + # build takes significantly longer than 1 second, so this shouldn't + # impact the measurements + time.sleep(1) + + # If there are jobs in the queue, the loop didn't complete in the allocated + # time. So something went wrong, like a build getting stuck or parallel + # builds not working + assert len(build_ids) == 0 + + # Because this is an integration test, we cannot change the server + # c.CondaStoreWorker.concurrency value, which is set to 4 by default. But + # it's possible to devise a statistical test based on locally collected + # data, using 'build_deltas' above: + # + # concurrency = 4 with 2 CPUs, so equivalent to concurrency = 2: + # c4_2cpu = [ + # 1.027987, + # 67.234371, + # 1.272288, + # 43.966526, + # 7.171627, + # 68.563222, + # 4.143675, + # 46.872263, + # 1.018258, + # ] + # + # concurrency = 1, same machine: + # c1 = [ + # 19.394085, + # 33.70623, + # 22.815429, + # 62.555845, + # 68.438333, + # 29.794979, + # 63.370743, + # 32.118421, + # 29.88376, + # ] + # + # Here's another set of measurements from a different machine, which should + # have 4 CPUs, with concurrency = 4: + # ci = [ + # 1.02644, + # 33.591736, + # 1.016269, + # 19.299738, + # 7.115025, + # 20.342442, + # 12.222805, + # 6.103751, + # 1.016237, + # ] + # + # These values will vary depending on workload and the number of CPUs. But + # the main observation here is this: if parallel builds are working, + # there will be a number of values that are relatively small compared to the + # time it takes to run a single build when not running concurrently. + # + # So the test below looks at the value of the first quartile, which is the + # median of the lower half of the dataset, where all these small values will + # be located, and compares it to a certain threshold, which is unlikely to + # be reached based on how long it takes a single build to run + # non-concurrently on average: + threshold = 10 + quartiles = statistics.quantiles(build_deltas, method="inclusive") + print("build_deltas", build_deltas) + print("stats", min(build_deltas), quartiles) + assert quartiles[0] < threshold + + # Only testing size values that will always cause errors. Smaller values could # cause errors as well, but would be flaky since the test conda-store state # directory might have different lengths on different systems, for instance, @@ -485,7 +659,7 @@ def test_create_specification_auth(testclient): ) def test_create_specification_auth_env_name_too_long(testclient, size): namespace = "default" - environment_name = 'A' * size + environment_name = "A" * size testclient.login() response = testclient.post( @@ -506,8 +680,8 @@ def test_create_specification_auth_env_name_too_long(testclient, size): # Try checking that the status is 'FAILED' is_updated = False - for _ in range(5): - time.sleep(5) + for _ in range(60): + time.sleep(10) # check for the given build response = testclient.get(f"api/v1/build/{build_id}") @@ -525,7 +699,7 @@ def test_create_specification_auth_env_name_too_long(testclient, size): # If we're here, the task didn't update the status on failure if not is_updated: - assert False, f"failed to update status" + assert False, "failed to update status" def test_create_specification_auth_no_namespace_specified(testclient): @@ -612,7 +786,7 @@ def test_create_namespace_auth(testclient): def test_update_namespace_noauth(testclient): - namespace = f"filesystem" + namespace = "filesystem" # namespace = f"pytest-{uuid.uuid4()}" test_role_mappings = { @@ -664,7 +838,7 @@ def test_update_namespace_noauth(testclient): ], ) def test_update_namespace_auth(testclient, editor_role): - namespace = f"filesystem" + namespace = "filesystem" testclient.login() @@ -737,7 +911,9 @@ def test_create_get_delete_namespace_auth(testclient): assert r.status == schema.APIStatus.ERROR -def _crud_common(testclient, auth, method, route, params=None, json=None, data_pred=None): +def _crud_common( + testclient, auth, method, route, params=None, json=None, data_pred=None +): if auth: testclient.login() @@ -766,7 +942,7 @@ def _crud_common(testclient, auth, method, route, params=None, json=None, data_p @pytest.mark.parametrize("auth", [True, False]) def test_update_namespace_metadata_v2(testclient, auth): - namespace = f"filesystem" + namespace = "filesystem" make_request = partial(_crud_common, testclient=testclient, auth=auth) make_request( @@ -786,7 +962,7 @@ def test_update_namespace_metadata_v2(testclient, auth): ) def test_crud_namespace_roles_v2(testclient, auth, editor_role): other_namespace = f"pytest-{uuid.uuid4()}" - namespace = f"filesystem" + namespace = "filesystem" make_request = partial(_crud_common, testclient=testclient, auth=auth) # Deletes roles to start with a clean state @@ -819,9 +995,9 @@ def test_crud_namespace_roles_v2(testclient, auth, editor_role): "other_namespace": other_namespace, }, data_pred=lambda data: ( - data['namespace'] == 'filesystem' and - data['other_namespace'] == other_namespace and - data['role'] == 'developer' # always developer in the DB + data["namespace"] == "filesystem" + and data["other_namespace"] == other_namespace + and data["role"] == "developer" # always developer in the DB ), ) @@ -829,10 +1005,7 @@ def test_crud_namespace_roles_v2(testclient, auth, editor_role): make_request( method=testclient.put, route=f"api/v1/namespace/{namespace}/role", - json={ - "other_namespace": other_namespace, - "role": "admin" - }, + json={"other_namespace": other_namespace, "role": "admin"}, ) # Reads updated roles @@ -840,10 +1013,10 @@ def test_crud_namespace_roles_v2(testclient, auth, editor_role): method=testclient.get, route=f"api/v1/namespace/{namespace}/roles", data_pred=lambda data: ( - data[0]['namespace'] == 'filesystem' and - data[0]['other_namespace'] == other_namespace and - data[0]['role'] == 'admin' and - len(data) == 1 + data[0]["namespace"] == "filesystem" + and data[0]["other_namespace"] == other_namespace + and data[0]["role"] == "admin" + and len(data) == 1 ), ) @@ -997,7 +1170,7 @@ def test_api_cancel_build_auth(testclient): new_build_id = r.data.build_id # Delay to ensure the build kicks off - build_timeout = 180 + build_timeout = 10 * 60 building = False start = time.time() while time.time() - start < build_timeout: @@ -1025,7 +1198,7 @@ def test_api_cancel_build_auth(testclient): assert r.status == schema.APIStatus.OK assert r.message == f"build {new_build_id} canceled" - failed = False + canceled = False for _ in range(10): # Delay to ensure the build is marked as failed time.sleep(5) @@ -1041,8 +1214,14 @@ def test_api_cancel_build_auth(testclient): r = schema.APIGetBuild.parse_obj(response.json()) assert r.status == schema.APIStatus.OK assert r.data.id == new_build_id - if r.data.status == schema.BuildStatus.FAILED.value: - failed = True + if r.data.status == schema.BuildStatus.CANCELED.value: + canceled = True + response = testclient.get(f"api/v1/build/{new_build_id}/logs", timeout=10) + response.raise_for_status() + assert ( + f"build {new_build_id} marked as CANCELED " + f"due to being canceled from the REST API" + ) in response.text break - assert failed is True + assert canceled is True diff --git a/tests/test_playwright.py b/tests/test_playwright.py index f8f6878d6..f9fd0f7be 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -7,7 +7,10 @@ @pytest.mark.playwright def test_integration(page: Page, server_port): # Go to http://localhost:{server_port}/conda-store/admin/ - page.goto(f"http://localhost:{server_port}/conda-store/admin/", wait_until="domcontentloaded") + page.goto( + f"http://localhost:{server_port}/conda-store/admin/", + wait_until="domcontentloaded", + ) page.screenshot(path="test-results/conda-store-unauthenticated.png") # Click text=Login