Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Deploy app from git repository #445

Merged
merged 32 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6278b0e
get app config from git
aktech Aug 8, 2024
8e51a19
add documentation for getting app config from git
aktech Aug 8, 2024
3331010
remove unused vars
aktech Aug 8, 2024
e52c798
add git url in spawner
aktech Aug 8, 2024
f65e13d
add example
aktech Aug 18, 2024
396910f
add positive and neg tests
aktech Aug 18, 2024
5fca746
move app from repo test to e2e section
aktech Aug 18, 2024
aa0350a
add test for invalid repository
aktech Aug 18, 2024
c465bcf
improve description
aktech Aug 18, 2024
c1c35fa
git repo branch add to user options
aktech Aug 18, 2024
70cf805
add test for create server from git repo
aktech Aug 19, 2024
dd43206
replace aktech with jovyan
aktech Aug 19, 2024
b8fcdcf
use conda-project instead
aktech Aug 30, 2024
6f8f347
fix linting
aktech Aug 30, 2024
2a541bb
move conda project import to inside function
aktech Sep 3, 2024
761ac5f
remove unnecessary config file
aktech Sep 3, 2024
58c38ed
app config from conda project
aktech Sep 3, 2024
a395e2e
log message on failure
aktech Sep 3, 2024
ab4ca4b
move app from git to separate module
aktech Sep 3, 2024
3fe6b31
move app from git to a separate directory
aktech Sep 3, 2024
56a45aa
add more docs
aktech Sep 3, 2024
20bab46
add test to parse jhub-app config from conda-project yml
aktech Sep 3, 2024
d03d4cf
use repository object for server creation
aktech Sep 3, 2024
a5dd2f3
update create server with git repo test
aktech Sep 3, 2024
625304b
add integration tests for api
aktech Sep 3, 2024
e8782d4
set oauth_no_confirm=True
aktech Sep 3, 2024
62c9560
remove clicking on authorize
aktech Sep 3, 2024
817d746
always create artifact name
aktech Sep 3, 2024
935dc21
extract common items for user options
aktech Sep 6, 2024
1bbd593
add a line about cloning repository
aktech Sep 16, 2024
37a78fa
add another suffix to app name
aktech Sep 16, 2024
2906163
Apply suggestions from code review
aktech Sep 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ jobs:

- name: Create artifact name
id: artifact-name
if: always()
run: |
if [ "${{ matrix.jupyterhub }}" = "4.1.5" ]; then
jhub_suffix="4x"
Expand Down
2 changes: 2 additions & 0 deletions environment-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ dependencies:
- cachetools
- structlog
- gradio
- gitpython
- conda-project=0.4.2
126 changes: 126 additions & 0 deletions jhub_apps/service/app_from_git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import os
import pathlib
import tempfile
from pathlib import Path

import git
from fastapi import HTTPException
from pydantic import ValidationError
from starlette import status
aktech marked this conversation as resolved.
Show resolved Hide resolved

from jhub_apps.service.models import Repository, JHubAppConfig
from jhub_apps.service.utils import logger, encode_file_to_data_url


def _clone_repo(repository: Repository, temp_dir):
"""Clone repository to the given tem_dir"""
try:
logger.info("Trying to clone repository", repo_url=repository.url)
git.Repo.clone_from(repository.url, temp_dir, depth=1, branch=repository.ref)
aktech marked this conversation as resolved.
Show resolved Hide resolved
except Exception as e:
message = f"Repository clone failed: {repository.url}"
logger.error(message, repo_url=repository.url)
logger.error(e)
raise HTTPException(
detail=message,
status_code=status.HTTP_400_BAD_REQUEST,
)


def _get_app_configuration_from_git(
repository: Repository
) -> JHubAppConfig:
"""Clones the git directory into a temporary path and extracts all the metadata
about the app from conda-project's config yaml.
"""
with tempfile.TemporaryDirectory() as temp_dir:
_clone_repo(repository, temp_dir)
_check_conda_project_config_directory_exists(repository, temp_dir)
conda_project_yaml = _get_conda_project_config_yaml(temp_dir)
jhub_apps_config_dict = _extract_jhub_apps_config_from_conda_project_config(conda_project_yaml)
app_config = _load_jhub_app_config_to_pydantic_model(
jhub_apps_config_dict,
repository,
temp_dir
)
return app_config


def _load_jhub_app_config_to_pydantic_model(
jhub_apps_config_dict: dict, repository: Repository, temp_dir: str
):
"""Load the parsed jhub-apps config into pydantic model for validation"""
thumbnail_base64 = ""
thumbnail_path_from_config = jhub_apps_config_dict.get("thumbnail_path")
if thumbnail_path_from_config:
thumbnail_path = Path(os.path.join(temp_dir, thumbnail_path_from_config))
thumbnail_base64 = encode_file_to_data_url(
filename=thumbnail_path.name, file_contents=thumbnail_path.read_bytes()
)
try:
# Load YAML content into the Pydantic model
app_config = JHubAppConfig(**{
**jhub_apps_config_dict,
"repository": repository,
"thumbnail": thumbnail_base64,
"env": jhub_apps_config_dict.get("environment", {})
})
except ValidationError as e:
message = f"Validation error: {e}"
logger.error(message)
raise HTTPException(
detail=message,
status_code=status.HTTP_400_BAD_REQUEST,
)
return app_config


def _extract_jhub_apps_config_from_conda_project_config(conda_project_yaml):
"""Extracts jhub-apps app config from conda project yaml's config"""
jhub_apps_variables = {
k.split("JHUB_APP_CONFIG_")[-1]: v for k, v in conda_project_yaml.variables.items()
if k.startswith("JHUB_APP_CONFIG_")
}
environment_variables = {
k: v for k, v in conda_project_yaml.variables.items()
if not k.startswith("JHUB_APP_CONFIG_")
}
return {
**jhub_apps_variables,
"environment": environment_variables
}


def _get_conda_project_config_yaml(directory: str):
"""Given the directory, get conda project config object"""
# Moving this to top level import causes this problem:
# https://github.com/jupyter/jupyter_events/issues/99
from conda_project import CondaProject, CondaProjectError
from conda_project.project_file import CondaProjectYaml
try:
conda_project = CondaProject(directory)
# This is a private attribute, ideally we shouldn't access it,
# but I haven't found an alternative way to get this
conda_project_yaml: CondaProjectYaml = conda_project._project_file
except CondaProjectError as e:
message = "Invalid conda-project"
logger.error(message)
logger.exception(e)
raise HTTPException(
detail=message,
status_code=status.HTTP_400_BAD_REQUEST,
)
return conda_project_yaml


def _check_conda_project_config_directory_exists(repository: Repository, temp_dir: str):
"""Check if the conda project config directory provided by the user exists"""
temp_dir_path = pathlib.Path(temp_dir)
conda_project_dir = temp_dir_path / repository.config_directory
if not conda_project_dir.exists():
message = f"Path '{repository.config_directory}' doesn't exists in the repository."
logger.error(message, repo_url=repository.url)
raise HTTPException(
detail=message,
status_code=status.HTTP_400_BAD_REQUEST,
)
23 changes: 17 additions & 6 deletions jhub_apps/service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,34 @@ class HubApiError(BaseModel):
detail: HubResponse


class UserOptions(BaseModel):
jhub_app: bool
class Repository(BaseModel):
url: str
config_directory: str = "."
# git ref
ref: str = "main"


class JHubAppConfig(BaseModel):
display_name: str
description: str
thumbnail: str = None
filepath: typing.Optional[str] = str()
framework: str = "panel"
custom_command: typing.Optional[str] = str()
conda_env: typing.Optional[str] = str()
# Environment variables
env: typing.Optional[dict] = dict()
profile: typing.Optional[str] = str()
# Make app available to public (unauthenticated Hub users)
public: typing.Optional[bool] = False
# Keep app alive, even when there is no activity
# So that it's not killed by idle culler
keep_alive: typing.Optional[bool] = False
# Environment variables
env: typing.Optional[dict] = dict()
repository: typing.Optional[Repository] = None
aktech marked this conversation as resolved.
Show resolved Hide resolved


class UserOptions(JHubAppConfig):
jhub_app: bool
conda_env: typing.Optional[str] = str()
profile: typing.Optional[str] = str()
share_with: typing.Optional[SharePermissions] = None


Expand Down
22 changes: 21 additions & 1 deletion jhub_apps/service/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
AuthorizationError,
HubApiError,
ServerCreation,
User,
User, Repository,
aktech marked this conversation as resolved.
Show resolved Hide resolved
JHubAppConfig,
)
from jhub_apps.service.security import get_current_user
from jhub_apps.service.utils import (
Expand All @@ -37,6 +38,7 @@
get_thumbnail_data_url,
get_shared_servers,
)
from jhub_apps.service.app_from_git import _get_app_configuration_from_git
from jhub_apps.spawner.types import FRAMEWORKS
from jhub_apps.version import get_version

Expand Down Expand Up @@ -271,6 +273,24 @@ async def hub_services(user: User = Depends(get_current_user)):
return hub_client.get_services()


@router.post("/app-config-from-git/",)
async def app_from_git(
repo: Repository,
user: User = Depends(get_current_user)
) -> JHubAppConfig:
"""
## Fetches jhub-apps application configuration from a git repository.

Note: This endpoint is kept as POST intentionally because the client is
requesting the server to process some data, in this case, to fetch
a repository, read its conda project config, and return specific values,
which is a processing action.
"""
logger.info("Getting app configuration from git repository")
response = _get_app_configuration_from_git(repo)
return response


@router.get("/")
@router.get("/status")
async def status_endpoint():
Expand Down
14 changes: 14 additions & 0 deletions jhub_apps/spawner/spawner_creation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import uuid

import structlog

from jhub_apps.spawner.utils import get_origin_host
Expand Down Expand Up @@ -56,6 +58,18 @@ def get_args(self):
command = Command(args=GENERIC_ARGS + custom_cmd.split())
else:
command: Command = COMMANDS.get(framework)

repository = self.user_options.get("repository")
if repository:
logger.info(f"repository specified: {repository}")
# The repository will be cloned during spawn time to
# deploy the app from the repository.
command.args.extend([
f"--repo={repository.get('url')}",
f"--repofolder=/tmp/{self.name}-{uuid.uuid4().hex[:6]}",
f"--repobranch={repository.get('ref')}"
])

command_args = command.get_substituted_args(
python_exec=self.config.JAppsConfig.python_exec,
filepath=app_filepath,
Expand Down
3 changes: 3 additions & 0 deletions jhub_apps/tests/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
MOCK_USER.name = "jovyan"

JUPYTERHUB_HOSTNAME = "127.0.0.1:8000"
JUPYTERHUB_USERNAME = "admin"
JUPYTERHUB_PASSWORD = "admin"
JHUB_APPS_API_BASE_URL = f"http://{JUPYTERHUB_HOSTNAME}/services/japps"
106 changes: 106 additions & 0 deletions jhub_apps/tests/tests_e2e/test_api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,110 @@
import hashlib

import pytest

from jhub_apps.service.models import Repository, UserOptions, ServerCreation
from jhub_apps.tests.common.constants import JHUB_APPS_API_BASE_URL, JUPYTERHUB_HOSTNAME
from jhub_apps.tests.tests_e2e.utils import get_jhub_apps_session, fetch_url_until_title_found

EXAMPLE_TEST_REPO = "https://github.com/nebari-dev/jhub-apps-from-git-repo-example.git"


def test_api_status(client):
response = client.get("/status")
assert response.status_code == 200
assert set(response.json().keys()) == {"version", "status"}


def test_app_config_from_git_api(
client,
):
response = client.post(
'/app-config-from-git/',
json={
"url": EXAMPLE_TEST_REPO,
"config_directory": ".",
"ref": "main"
}
)
assert response.status_code == 200
response_json = response.json()
assert response_json
assert set(response_json.keys()) == {
"display_name", "description", "framework", "filepath",
"env", "keep_alive", "public", "thumbnail",
"custom_command", "repository"
}
assert response_json["display_name"] == "My Panel App (Git)"
assert response_json["description"] == "This is a panel app created from git repository"
assert response_json["framework"] == "panel"
assert response_json["filepath"] == "panel_basic.py"
assert response_json["env"] == {
"SOMETHING_FOO": "bar",
"SOMETHING_BAR": "beta",
}
assert response_json["keep_alive"] is False
assert response_json["public"] is False

assert isinstance(response_json["thumbnail"], str)
expected_thumbnail_sha = "a8104b2482360eee525dc696dafcd2a17864687891dc1b6c9e21520518a5ea89"
assert hashlib.sha256(response_json["thumbnail"].encode('utf-8')).hexdigest() == expected_thumbnail_sha


@pytest.mark.parametrize("repo_url, config_directory, response_status_code,detail", [
(EXAMPLE_TEST_REPO, "non-existent-path", 400,
"Path 'non-existent-path' doesn't exists in the repository."),
("http://invalid-repo/", ".", 400,
"Repository clone failed: http://invalid-repo/"),
])
def test_app_config_from_git_api_invalid(
client,
repo_url,
config_directory,
response_status_code,
detail
):
response = client.post(
'/app-config-from-git/',
json={
"url": repo_url,
"config_directory": config_directory,
"ref": "main"
}
)
assert response.status_code == response_status_code
response_json = response.json()
assert "detail" in response_json
assert response_json["detail"] == detail


def test_create_server_with_git_repository():
user_options = UserOptions(
jhub_app=True,
display_name="Test Application",
description="App description",
framework="panel",
thumbnail="data:image/png;base64,ZHVtbXkgaW1hZ2UgZGF0YQ==",
filepath="panel_basic.py",
repository=Repository(
url="https://github.com/nebari-dev/jhub-apps-from-git-repo-example.git",
)
)
files = {"thumbnail": ("test.png", b"dummy image data", "image/png")}
server_data = ServerCreation(
servername="test server from git repo",
user_options=user_options
)
data = {"data": server_data.model_dump_json()}
session = get_jhub_apps_session()
response = session.post(
f"{JHUB_APPS_API_BASE_URL}/server",
verify=False,
data=data,
files=files
)
assert response.status_code == 200
server_name = response.json()[-1]
created_app_url = f"http://{JUPYTERHUB_HOSTNAME}/user/admin/{server_name}/"
fetch_url_until_title_found(
session, url=created_app_url, expected_title="Panel Test App from Git Repository"
)
2 changes: 0 additions & 2 deletions jhub_apps/tests/tests_e2e/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,6 @@ def sign_in_and_authorize(page, username, password):
page.get_by_label("Password:").fill(password)
logger.info("Pressing Sign in button")
page.get_by_role("button", name="Sign in").click()
logger.info("Click Authorize button")
page.get_by_role("button", name="Authorize").click()


def sign_out(page):
Expand Down
Loading
Loading