From 282f5c0e414f1f405940c15bea52844093f59a06 Mon Sep 17 00:00:00 2001 From: Michael Woolnough <130465766+mjkw31@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:47:08 +0100 Subject: [PATCH] Handle recipe requests. (#58) * Add backend code to handle recipes requests from the frontend. * Add script for admin handling of Recipe Requests. --- README.md | 21 ++- schema.graphql | 2 + scripts/recipe-requests.sh | 90 ++++++++++ softpack_core/artifacts.py | 119 +++++++++++-- softpack_core/config/conf/config.yml | 6 + softpack_core/config/models.py | 8 + softpack_core/config/settings.py | 2 + softpack_core/schemas/environment.py | 20 ++- softpack_core/service.py | 176 ++++++++++++++++++- tests/integration/test_artifacts.py | 101 ++++++++++- tests/integration/test_environment.py | 18 +- tests/integration/test_recipe_requests.py | 202 ++++++++++++++++++++++ tests/integration/utils.py | 6 + 13 files changed, 736 insertions(+), 35 deletions(-) create mode 100755 scripts/recipe-requests.sh create mode 100644 tests/integration/test_recipe_requests.py diff --git a/README.md b/README.md index c2dd3c6..1408b40 100644 --- a/README.md +++ b/README.md @@ -202,22 +202,27 @@ server: artifacts: path: Path # Path to store artefacts repo. repo: - url: AnyUrl # URL to artefacts repo. + url: AnyUrl # URL to artefacts repo. username: Optional[str] # Username required to access artefacts repo. - author: str # Author name for git commits to artefacts repo. - email: str # Email address for author of git commits to artefacts repo. - reader: Optional[str] # Auth token for read access to artefacts repo. - writer: Optional[str] # Auth token for write access to artefacts repo. - branch: Optional[str] # Branch to use for artefacts repo. + author: str # Author name for git commits to artefacts repo. + email: str # Email address for author of git commits to artefacts repo. + reader: Optional[str] # Auth token for read access to artefacts repo. + writer: Optional[str] # Auth token for write access to artefacts repo. + branch: Optional[str] # Branch to use for artefacts repo. spack: - repo: str # URL to spack recipe repo. - bin: str # Path to spack exectable. + repo: str # URL to spack recipe repo. + bin: str # Path to spack exectable. cache: Optional[str] # Directory to store cached spack recipe information. builder: host: str # URL to a GSB server port: int # Port of the GSB server + +recipes: + toAddr: Optional[str] # Address to which recipe requests will be sent. + fromAddr: Optional[str] # Address from which recipe requests will be sent. + smtp: Optional[str] # Address to an SMTP relay ``` ## Usage diff --git a/schema.graphql b/schema.graphql index 5184ce9..a13964d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -39,6 +39,7 @@ type Environment { state: State tags: [String!]! hidden: Boolean! + cachedEnvs: [Environment!]! requested: DateTime buildStart: DateTime buildDone: DateTime @@ -117,6 +118,7 @@ enum State { ready queued failed + waiting } interface Success { diff --git a/scripts/recipe-requests.sh b/scripts/recipe-requests.sh new file mode 100755 index 0000000..69aa4bb --- /dev/null +++ b/scripts/recipe-requests.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +set -euo pipefail; + +declare CORE_URL="http://127.0.0.1:8000"; + +IFS=$'\n'; + +while true; do + declare -a data=( $(curl "$CORE_URL/requestedRecipes" 2> /dev/null | jq -c '.[]') ) || { + echo "Error: failed to get data from Core." >&2; + + exit 1; + } + + if [ "${#data[@]}" -eq 0 ]; then + echo "No requested recipes." + + exit 0; + fi; + + declare num=0; + + for recipe in "${data[@]}";do + let "num+=1"; + + echo -n "$num: "; + echo "$recipe" | jq "\"\(.name)@\(.version)\"" -r; + done; + + echo "q. Quit"; + + read num; + + if [ "$num" = "q" -o "$num" = "Q" ]; then + exit 0; + fi; + + if [ "$num" -gt 0 -a "$num" -le "${#data[@]}" ] 2> /dev/null; then + while true; do + echo "${data[$((num - 1))]}" | jq "\"\(.name)@\(.version)\\nUsername: \(.username)\\nURL: \(.url)\\nDescription: \(.description)\"" -r; + + echo -e "F. Fufill Request\nR. Remove Request\nB. Back"; + + read v; + + case "$v" in + "f"|"F") + echo -n "Enter new package name (blank to keep existing): "; + + read pkgname; + + echo -n "Enter new package version (blank to keep existing): "; + + read pkgversion + + if curl -d "$({ + echo "${data[$((num - 1))]}" | jq '{"requestedName": .name, "requestedVersion": .version}' -c; + echo "${data[$((num - 1))]}" | jq '{"name","version"}' -c; + echo -e "$pkgname\n$pkgversion" | jq -s -R 'split("\n")[:2] | to_entries | map({(if .key == 0 then "name" else "version" end): .value}) | add | del(..|select(. == ""))' -c; + } | jq -s add -c)" "$CORE_URL/fulfilRequestedRecipe"; then + echo; + + break; + fi;; + "r"|"R") + echo -n "Are you sure you want to remove the request $(echo "${data[$((num - 1))]}" | jq "\"\(.name)@\(.version)\"" -r)? (yN): "; + + read v; + + if [ "$v" = "y" -o "$v" = "Y" ]; then + if curl -d "$(echo "${data[$((num - 1))]}" | jq 'with_entries(select([.key] | inside(["name", "version"])))')" "$CORE_URL/removeRequestedRecipe"; then + echo; + + break; + fi; + else + echo "Invalid Response."; + fi;; + "b"|"B") + break;; + *) + echo "Invalid Response.";; + esac; + done; + + else + echo "Invalid Response." + fi; +done; diff --git a/softpack_core/artifacts.py b/softpack_core/artifacts.py index 6f9681e..9d6bbb0 100644 --- a/softpack_core/artifacts.py +++ b/softpack_core/artifacts.py @@ -10,10 +10,11 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path -from typing import Iterable, Iterator, List, Optional, Tuple, Union +from typing import Iterable, Iterator, List, Optional, Tuple, Union, cast import pygit2 import strawberry +import yaml from box import Box from fastapi import UploadFile @@ -55,6 +56,7 @@ class State(Enum): ready = 'ready' queued = 'queued' failed = 'failed' + waiting = 'waiting' @strawberry.enum @@ -68,6 +70,7 @@ class Type(Enum): class Artifacts: """Artifacts repo access class.""" + recipes_root = "recipes" environments_root = "environments" environments_file = "softpack.yml" builder_out = "builder.out" @@ -141,13 +144,6 @@ def spec(self) -> Box: if Artifacts.readme_file in self.obj: info["readme"] = self.obj[Artifacts.readme_file].data.decode() - if Artifacts.module_file in self.obj: - info["state"] = State.ready - elif Artifacts.builder_out in self.obj: - info["state"] = State.failed - else: - info["state"] = State.queued - if Artifacts.generated_from_module_file in self.obj: info["type"] = Artifacts.generated_from_module else: @@ -157,6 +153,15 @@ def spec(self) -> Box: map(lambda p: Package.from_name(p), info.packages) ) + if Artifacts.module_file in self.obj: + info["state"] = State.ready + elif Artifacts.builder_out in self.obj: + info["state"] = State.failed + elif any(pkg.name.startswith("*") for pkg in info.packages): + info["state"] = State.waiting + else: + info["state"] = State.queued + metadata = self.metadata() info["tags"] = getattr(metadata, "tags", []) @@ -210,6 +215,16 @@ def __init__(self) -> None: credentials=credentials ) + @dataclass + class RecipeObject: + """The Recipe object represents the data for a requested recipe.""" + + name: str + version: str + description: str + url: str + username: str + @property def signature(self) -> pygit2.Signature: """Get current pygit2 commit signature: author/committer/timestamp.""" @@ -389,6 +404,71 @@ def get(self, path: Path, name: str) -> Optional[Object]: except KeyError: return None + def create_recipe_request(self, recipe: RecipeObject) -> pygit2.Oid: + """Create a recipe request. + + Args: + recipe: the recipe to be created. + """ + return self.commit_and_push( + self.create_file( + Path(self.recipes_root), + recipe.name + "-" + recipe.version, + yaml.dump(recipe), + True, + ), + "add recipe request", + ) + + def get_recipe_request( + self, name: str, version: str + ) -> Optional[RecipeObject]: + """Get the details of a requested recipe. + + Args: + name: the name of the recipe. + version: the version of the recipe. + """ + try: + recipeData = self.tree(self.recipes_root)[name + "-" + version] + except Exception: + return None + + if not recipeData: + return None + + return cast(Artifacts.RecipeObject, Box.from_yaml(recipeData.data)) + + def remove_recipe_request(self, name: str, version: str) -> pygit2.Oid: + """Remove a recipe request. + + Args: + name: the name of the recipe. + version: the version of the recipe. + """ + try: + recipeData = self.tree(self.recipes_root)[name + "-" + version] + except Exception: + raise FileNotFoundError("recipe request does not exist") + + if not recipeData: + raise FileNotFoundError("recipe request does not exist") + + return self.commit_and_push( + self.remove(Path(self.recipes_root), name + "-" + version), + "removed recipe request", + ) + + def iter_recipe_requests(self) -> Iterable[RecipeObject]: + """Iterate over recipe requests.""" + try: + tree = self.tree(self.recipes_root) + except Exception: + return [] + + for recipe in tree: + yield cast(Artifacts.RecipeObject, Box.from_yaml(recipe.data)) + def commit_and_push( self, tree_oid: pygit2.Oid, message: str ) -> pygit2.Oid: @@ -471,7 +551,7 @@ def create_file( def create_files( self, - folder_path: Path, + full_path: Path, files: List[Tuple[str, Union[str, UploadFile]]], new_folder: bool = False, overwrite: bool = False, @@ -490,13 +570,17 @@ def create_files( the OID of the new tree structure of the repository """ for file_name, _ in files: - if not overwrite and self.get(Path(folder_path), file_name): + try: + tree = self.tree(str(full_path)) + except Exception: + continue + + if not overwrite and tree and file_name in tree: raise FileExistsError("File already exists") root_tree = self.repo.head.peel(pygit2.Tree) - full_path = Path(self.environments_root, folder_path) - if new_folder: + if new_folder and (full_path not in root_tree or overwrite): new_treebuilder = self.repo.TreeBuilder() else: folder = root_tree[full_path] @@ -544,10 +628,19 @@ def delete_environment( if len(Path(path).parts) != 2: raise ValueError("Not a valid environment path") + return self.remove(Path(self.environments_root, path), name) + + def remove(self, full_path: Path, name: str) -> pygit2.Oid: + """Remove a target from the artefacts repo. + + Args: + full_path: the parent directory to remove from + name: the file/directory to remove from the parent + commit_message: the commit message + """ # Get repository tree root_tree = self.repo.head.peel(pygit2.Tree) # Find environment in the tree - full_path = Path(self.environments_root, path) target_tree = root_tree[full_path] # Remove the environment tree_builder = self.repo.TreeBuilder(target_tree) diff --git a/softpack_core/config/conf/config.yml b/softpack_core/config/conf/config.yml index 2a9f50e..1ff75f4 100644 --- a/softpack_core/config/conf/config.yml +++ b/softpack_core/config/conf/config.yml @@ -25,6 +25,12 @@ spack: repo: https://github.com/custom-spack/repo bin: /path/to/spack +recipes: + toAddr: none@domain.com + fromAddr: none@domain.com + smtp: some.mail.relay + + # Vault Config # vault: # url: diff --git a/softpack_core/config/models.py b/softpack_core/config/models.py index c540dad..9313afe 100644 --- a/softpack_core/config/models.py +++ b/softpack_core/config/models.py @@ -78,3 +78,11 @@ class SpackConfig(BaseModel): repo: str bin: str cache: Optional[str] + + +class RecipeConfig(BaseModel): + """Email settings to send recipe requests to.""" + + toAddr: Optional[str] + fromAddr: Optional[str] + smtp: Optional[str] diff --git a/softpack_core/config/settings.py b/softpack_core/config/settings.py index 4896ee1..f48202c 100644 --- a/softpack_core/config/settings.py +++ b/softpack_core/config/settings.py @@ -18,6 +18,7 @@ ArtifactsConfig, BuilderConfig, LDAPConfig, + RecipeConfig, ServerConfig, SpackConfig, VaultConfig, @@ -34,6 +35,7 @@ class Settings(BaseSettings): artifacts: ArtifactsConfig spack: SpackConfig builder: BuilderConfig + recipes: RecipeConfig class Config: """Configuration loader.""" diff --git a/softpack_core/schemas/environment.py b/softpack_core/schemas/environment.py index f036d84..3344fa9 100644 --- a/softpack_core/schemas/environment.py +++ b/softpack_core/schemas/environment.py @@ -266,6 +266,10 @@ def from_path(cls, environment_path: str) -> 'EnvironmentInput': packages=[PackageInput("placeholder")], ) + def has_requested_recipes(self) -> bool: + """Do any of the requested packages have an unmade recipe.""" + return any(pkg.name.startswith("*") for pkg in self.packages) + @dataclass class BuildStatus: @@ -375,6 +379,10 @@ def iter(cls) -> list["Environment"]: return environment_objects + def has_requested_recipes(self) -> bool: + """Do any of the requested packages have an unmade recipe.""" + return any(pkg.name.startswith("*") for pkg in self.packages) + @classmethod def from_artifact(cls, obj: Artifacts.Object) -> Optional["Environment"]: """Create an Environment object from an artifact. @@ -459,6 +467,9 @@ def submit_env_to_builder( message=f"could not parse version from name: {env.name!r}" ) + if env.has_requested_recipes(): + return None + try: host = app.settings.builder.host port = app.settings.builder.port @@ -537,7 +548,7 @@ def create_new_env( metaData = yaml.dump(meta) tree_oid = artifacts.create_files( - new_folder_path, + Path(artifacts.environments_root, new_folder_path), [ (env_type, ""), # e.g. .built_by_softpack ( @@ -547,6 +558,7 @@ def create_new_env( (artifacts.meta_file, metaData), ], True, + True, ) artifacts.commit_and_push(tree_oid, "create environment folder") except RuntimeError as e: @@ -642,7 +654,7 @@ def store_metadata(cls, environment_path: Path, metadata: Box) -> None: environment path given. """ tree_oid = artifacts.create_file( - environment_path, + Path(artifacts.environments_root, environment_path), artifacts.meta_file, metadata.to_yaml(), overwrite=True, @@ -850,7 +862,9 @@ async def write_artifacts( ) tree_oid = artifacts.create_files( - Path(folder_path), new_files, overwrite=True + Path(artifacts.environments_root, folder_path), + new_files, + overwrite=True, ) artifacts.commit_and_push(tree_oid, "write artifact") return WriteArtifactSuccess( diff --git a/softpack_core/service.py b/softpack_core/service.py index 0b2dc42..d2e079c 100644 --- a/softpack_core/service.py +++ b/softpack_core/service.py @@ -5,16 +5,19 @@ """ +import smtplib import urllib.parse +from email.mime.text import MIMEText from pathlib import Path import typer import uvicorn +import yaml from fastapi import APIRouter, Request, Response, UploadFile from typer import Typer from typing_extensions import Annotated -from softpack_core.artifacts import State, artifacts +from softpack_core.artifacts import Artifacts, State, artifacts from softpack_core.schemas.environment import ( CreateEnvironmentSuccess, Environment, @@ -121,6 +124,7 @@ async def resend_pending_builds( # type: ignore[no-untyped-def] for env in Environment.iter(): if env.state != State.queued: continue + result = Environment.submit_env_to_builder( EnvironmentInput( name=env.name, @@ -129,6 +133,7 @@ async def resend_pending_builds( # type: ignore[no-untyped-def] packages=[PackageInput(**vars(p)) for p in env.packages], ) ) + if result is None: successes += 1 else: @@ -145,3 +150,172 @@ async def resend_pending_builds( # type: ignore[no-untyped-def] "successes": successes, "failures": failures, } + + @staticmethod + @router.post("/requestRecipe") + async def request_recipe( # type: ignore[no-untyped-def] + request: Request, + ): + """Request a recipe to be created.""" + data = await request.json() + + for key in ("name", "version", "description", "url", "username"): + if key not in data or not isinstance(data[key], str): + return {"error": "Invalid Input"} + + try: + artifacts.create_recipe_request( + Artifacts.RecipeObject( + data["name"], + data["version"], + data["description"], + data["url"], + data["username"], + ) + ) + except Exception as e: + return {"error": str(e)} + + recipeConfig = app.settings.recipes.dict() + + if all( + key in recipeConfig and isinstance(recipeConfig[key], str) + for key in ["fromAddr", "toAddr", "smtp"] + ): + msg = MIMEText( + f'User: {data["username"]}\n' + + f'Recipe: {data["name"]}\n' + + f'Version: {data["version"]}\n' + + f'URL: {data["url"]}\n' + + f'Description: {data["description"]}' + ) + msg["Subject"] = "SoftPack Recipe Request" + msg["From"] = recipeConfig["fromAddr"] + msg["To"] = recipeConfig["toAddr"] + + s = smtplib.SMTP(recipeConfig["smtp"]) + s.sendmail( + recipeConfig["fromAddr"], + [recipeConfig["toAddr"]], + msg.as_string(), + ) + s.quit() + + return {"message": "Request Created"} + + @staticmethod + @router.get("/requestedRecipes") + async def requested_recipes( # type: ignore[no-untyped-def] + request: Request, + ): + """List requested recipes.""" + return list(artifacts.iter_recipe_requests()) + + @staticmethod + @router.post("/fulfilRequestedRecipe") + async def fulfil_recipe( # type: ignore[no-untyped-def] + request: Request, + ): + """Fulfil a recipe request.""" + data = await request.json() + + for key in ("name", "version", "requestedName", "requestedVersion"): + if not isinstance(data[key], str): + return {"error": "Invalid Input"} + + r = artifacts.get_recipe_request( + data["requestedName"], data["requestedVersion"] + ) + + if r is None: + return {"error": "Unknown Recipe"} + + for env in Environment.iter(): + if env.state != State.waiting: + continue + + changed = False + + for pkg in env.packages: + if ( + pkg.name.startswith("*") + and pkg.name[1:] == data["requestedName"] + and pkg.version == data["requestedVersion"] + ): + pkg.name = data["name"] + pkg.version = data["version"] + changed = True + + break + + if not changed: + continue + + artifacts.commit_and_push( + artifacts.create_file( + Path(Artifacts.environments_root, env.path, env.name), + Artifacts.environments_file, + yaml.dump( + dict( + description=env.description, + packages=[ + pkg.name + + ("@" + pkg.version if pkg.version else "") + for pkg in env.packages + ], + ) + ), + False, + True, + ), + "fulfil recipe request for environment", + ) + + if not env.has_requested_recipes(): + Environment.submit_env_to_builder( + EnvironmentInput( + name=env.name, + path=env.path, + description=env.description, + packages=[ + PackageInput(**vars(p)) for p in env.packages + ], + ) + ) + + artifacts.remove_recipe_request( + data["requestedName"], data["requestedVersion"] + ) + + return {"message": "Recipe Fulfilled"} + + @staticmethod + @router.post("/removeRequestedRecipe") + async def remove_recipe( # type: ignore[no-untyped-def] + request: Request, + ): + """Remove a recipe request.""" + data = await request.json() + + for key in ("name", "version"): + if not isinstance(data[key], str): + return {"error": "Invalid Input"} + + for env in Environment.iter(): + for pkg in env.packages: + if ( + pkg.name.startswith("*") + and pkg.name[1:] == data["name"] + and pkg.version == data["version"] + ): + return { + "error": "There are environments relying on this " + + "requested recipe; can not delete." + } + + try: + artifacts.remove_recipe_request(data["name"], data["version"]) + except Exception as e: + return {"error": e} + + return {"message": "Request Removed"} diff --git a/tests/integration/test_artifacts.py b/tests/integration/test_artifacts.py index 0f2484b..5c0de06 100644 --- a/tests/integration/test_artifacts.py +++ b/tests/integration/test_artifacts.py @@ -118,7 +118,11 @@ def test_create_file() -> None: basename = "create_file.txt" oid = artifacts.create_file( - folder_path, basename, "lorem ipsum", True, False + Path(artifacts.environments_root, folder_path), + basename, + "lorem ipsum", + True, + False, ) user_envs_tree = get_user_envs_tree(artifacts, user, oid) @@ -129,19 +133,31 @@ def test_create_file() -> None: with pytest.raises(RuntimeError) as exc_info: artifacts.create_file( - folder_path, basename, "lorem ipsum", False, True + Path(artifacts.environments_root, folder_path), + basename, + "lorem ipsum", + False, + True, ) assert exc_info.value.args[0] == 'No changes made to the environment' basename2 = "create_file2.txt" with pytest.raises(RuntimeError) as exc_info: artifacts.create_file( - folder_path, basename2, "lorem ipsum", True, False + Path(artifacts.environments_root, folder_path), + basename2, + "lorem ipsum", + True, + True, ) assert exc_info.value.args[0] == 'Too many changes to the repo' oid = artifacts.create_file( - folder_path, basename2, "lorem ipsum", False, False + Path(artifacts.environments_root, folder_path), + basename2, + "lorem ipsum", + False, + False, ) artifacts.commit_and_push(oid, "create file2") @@ -151,11 +167,21 @@ def test_create_file() -> None: with pytest.raises(FileExistsError) as exc_info: artifacts.create_file( - folder_path, basename, "lorem ipsum", False, False + Path(artifacts.environments_root, folder_path), + basename, + "lorem ipsum", + False, + False, ) assert exc_info.value.args[0] == 'File already exists' - oid = artifacts.create_file(folder_path, basename, "override", False, True) + oid = artifacts.create_file( + Path(artifacts.environments_root, folder_path), + basename, + "override", + False, + True, + ) artifacts.commit_and_push(oid, "update created file") @@ -272,3 +298,66 @@ def fn(i: int): for _ in range(parallelism): commit = commit.parents[0] assert commit.oid == initial_commit_oid + + +def test_recipes(): + ad = new_test_artifacts() + artifacts: Artifacts = ad["artifacts"] + + assert artifacts.get_recipe_request("recipeA", "1.23") is None + assert artifacts.get_recipe_request("recipeB", "0.1a") is None + + recipeA = Artifacts.RecipeObject( + "recipeA", "1.23", "A new recipe", "http://example.com", "user1" + ) + recipeB = Artifacts.RecipeObject( + "recipeB", + "0.1a", + "Another recipe", + "http://example.com/another", + "user2", + ) + + artifacts.create_recipe_request(recipeA) + + retrieved_recipe = artifacts.get_recipe_request("recipeA", "1.23") + + assert retrieved_recipe is not None + assert retrieved_recipe.name == recipeA.name + assert retrieved_recipe.version == recipeA.version + assert retrieved_recipe.description == recipeA.description + assert retrieved_recipe.url == recipeA.url + assert retrieved_recipe.username == recipeA.username + + exists = False + + try: + artifacts.create_recipe_request(recipeA) + except Exception: + exists = True + + assert exists + + artifacts.create_recipe_request(recipeB) + + requests = list(artifacts.iter_recipe_requests()) + + assert len(requests) == 2 + + removed = artifacts.remove_recipe_request("recipeA", "1.23") + + assert removed is not None + assert artifacts.get_recipe_request("recipeA", "1.23") is None + + retrieved_recipe = artifacts.get_recipe_request("recipeB", "0.1a") + + assert retrieved_recipe is not None + assert retrieved_recipe.name == recipeB.name + assert retrieved_recipe.version == recipeB.version + assert retrieved_recipe.description == recipeB.description + assert retrieved_recipe.url == recipeB.url + assert retrieved_recipe.username == recipeB.username + + requests = list(artifacts.iter_recipe_requests()) + + assert len(requests) == 1 diff --git a/tests/integration/test_environment.py b/tests/integration/test_environment.py index 949ff4f..c0ee77e 100644 --- a/tests/integration/test_environment.py +++ b/tests/integration/test_environment.py @@ -276,6 +276,7 @@ def test_iter(testable_env_input, mocker): }, ] + artifacts.updated = True envs = list(Environment.iter()) assert len(envs) == 2 assert envs[0].requested == datetime.datetime( @@ -297,10 +298,8 @@ def test_iter(testable_env_input, mocker): assert envs[0].avg_wait_secs == envs[1].avg_wait_secs == 20 -def test_iter_no_statuses(testable_env_input, mocker): - get_mock = mocker.patch("httpx.get") - get_mock.return_value.json.return_value = [] - +def test_iter_no_statuses(testable_env_input): + artifacts.updated = True envs = list(Environment.iter()) assert len(envs) == 2 assert envs[0].requested is None @@ -603,3 +602,14 @@ def test_force_hidden( new_first = Environment.iter()[0] assert first_env.path != new_first.path or first_env.name != new_first.name + + +def test_environment_with_requested_recipe( + httpx_post, testable_env_input: EnvironmentInput +) -> None: + testable_env_input.packages[0].name = ( + "*" + testable_env_input.packages[0].name + ) + result = Environment.create(testable_env_input) + assert isinstance(result, CreateEnvironmentSuccess) + httpx_post.assert_not_called() diff --git a/tests/integration/test_recipe_requests.py b/tests/integration/test_recipe_requests.py new file mode 100644 index 0000000..e7f52a7 --- /dev/null +++ b/tests/integration/test_recipe_requests.py @@ -0,0 +1,202 @@ +"""Copyright (c) 2024 Genome Research Ltd. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +""" + +from fastapi.testclient import TestClient + +from softpack_core.app import app +from softpack_core.schemas.environment import ( + CreateEnvironmentSuccess, + Environment, + EnvironmentInput, + PackageInput, +) +from tests.integration.utils import builder_called_correctly + + +def test_request_recipe(httpx_post, testable_env_input): + client = TestClient(app.router) + resp = client.post( + url="/requestRecipe", + json={ + "name": "a_recipe", + "version": "1.2", + "description": "A description", + "url": "http://example.com", + "username": "me", + }, + ) + + assert resp.json() == {"message": "Request Created"} + + resp = client.get(url="/requestedRecipes") + + assert resp.json() == [ + { + "name": "a_recipe", + "version": "1.2", + "description": "A description", + "url": "http://example.com", + "username": "me", + } + ] + + resp = client.post( + url="/requestRecipe", + json={ + "name": "a_recipe", + "version": "1.2", + "description": "A description", + "url": "http://example.com", + "username": "me", + }, + ) + + assert resp.json() == {"error": "File already exists"} + + resp = client.post( + url="/requestRecipe", + json={ + "nome": "a_recipe", + "version": "1.2", + "description": "A description", + "url": "http://example.com", + "username": "me", + }, + ) + + assert resp.json() == {"error": "Invalid Input"} + + resp = client.post( + url="/requestRecipe", + json={ + "name": "b_recipe", + "version": "1.4", + "description": "Another description", + "url": "http://example.com", + "username": "me2", + }, + ) + + assert resp.json() == {"message": "Request Created"} + + resp = client.get(url="/requestedRecipes") + + assert resp.json() == [ + { + "name": "a_recipe", + "version": "1.2", + "description": "A description", + "url": "http://example.com", + "username": "me", + }, + { + "name": "b_recipe", + "version": "1.4", + "description": "Another description", + "url": "http://example.com", + "username": "me2", + }, + ] + + env = EnvironmentInput.from_path("users/me/my_env-1") + env.packages = [ + PackageInput.from_name("pkg@1"), + PackageInput.from_name("*a_recipe@1.2"), + ] + + assert len(Environment.iter()) == 2 + assert isinstance(Environment.create(env), CreateEnvironmentSuccess) + + envs = Environment.iter() + + assert len(envs) == 3 + assert len(envs[0].packages) == 2 + assert envs[0].packages[0].name == "pkg" + assert envs[0].packages[0].version == "1" + assert envs[0].packages[1].name == "*a_recipe" + assert envs[0].packages[1].version == "1.2" + + httpx_post.assert_not_called() + + resp = client.post( + url="/fulfilRequestedRecipe", + json={ + "name": "finalRecipe", + "version": "1.2.1", + "requestedName": "a_recipe", + "requestedVersion": "1.2", + }, + ) + + assert resp.json() == {"message": "Recipe Fulfilled"} + + httpx_post.assert_called_once() + + env.name = "my_env-1" + env.packages[1] = PackageInput.from_name("finalRecipe@1.2.1") + + builder_called_correctly(httpx_post, env) + + envs = Environment.iter() + + assert len(envs) == 3 + assert len(envs[0].packages) == 2 + assert envs[0].packages[0].name == "pkg" + assert envs[0].packages[0].version == "1" + assert envs[0].packages[1].name == "finalRecipe" + assert envs[0].packages[1].version == "1.2.1" + + resp = client.get(url="/requestedRecipes") + + assert resp.json() == [ + { + "name": "b_recipe", + "version": "1.4", + "description": "Another description", + "url": "http://example.com", + "username": "me2", + } + ] + + resp = client.post( + url="/removeRequestedRecipe", + json={"name": "b_recipe", "version": "1.4"}, + ) + + assert resp.json() == {"message": "Request Removed"} + + resp = client.get(url="/requestedRecipes") + + assert resp.json() == [] + + resp = client.post( + url="/requestRecipe", + json={ + "name": "c_recipe", + "version": "0.9", + "description": "Lorem ipsum.", + "url": "http://example.com", + "username": "me", + }, + ) + + env = EnvironmentInput.from_path("users/me/my_env-2") + env.packages = [ + PackageInput.from_name("pkg@1"), + PackageInput.from_name("*c_recipe@0.9"), + ] + + assert isinstance(Environment.create(env), CreateEnvironmentSuccess) + + resp = client.post( + url="/removeRequestedRecipe", + json={"name": "c_recipe", "version": "0.9"}, + ) + + assert resp.json() == { + "error": "There are environments relying on this requested recipe; " + + "can not delete." + } diff --git a/tests/integration/utils.py b/tests/integration/utils.py index dc008b4..d644b5a 100644 --- a/tests/integration/utils.py +++ b/tests/integration/utils.py @@ -59,6 +59,12 @@ def delete_environments_folder_from_test_repo(artifacts: Artifacts): artifacts, oid, "delete environments" ) + if artifacts.recipes_root in tree: + treeBuilder = artifacts.repo.TreeBuilder(tree) + treeBuilder.remove(artifacts.recipes_root) + oid = treeBuilder.write() + commit_and_push_test_repo_changes(artifacts, oid, "delete recipes") + def commit_and_push_test_repo_changes( artifacts: Artifacts, oid: pygit2.Oid, msg: str