diff --git a/src/_nebari/initialize.py b/src/_nebari/initialize.py index 9c963ee70..7f4b7aab9 100644 --- a/src/_nebari/initialize.py +++ b/src/_nebari/initialize.py @@ -23,7 +23,7 @@ from _nebari.stages.terraform_state import TerraformStateEnum from _nebari.utils import get_latest_kubernetes_version, random_secure_string from _nebari.version import __version__ -from nebari.schema import ProviderEnum +from nebari.schema import ProviderEnum, github_url_regex logger = logging.getLogger(__name__) @@ -194,9 +194,8 @@ def render_config( print(str(e)) if repository_auto_provision: - GITHUB_REGEX = "(https://)?github.com/([^/]+)/([^/]+)/?" - if re.search(GITHUB_REGEX, repository): - match = re.search(GITHUB_REGEX, repository) + match = re.search(github_url_regex, repository) + if match: git_repository = github_auto_provision( config_model, match.group(2), match.group(3) ) @@ -230,7 +229,7 @@ def github_auto_provision(config: pydantic.BaseModel, owner: str, repo: str): f"Unable to create GitHub repo https://github.com/{owner}/{repo} - error message from GitHub is: {he}" ) else: - logger.warn(f"GitHub repo https://github.com/{owner}/{repo} already exists") + logger.warning(f"GitHub repo https://github.com/{owner}/{repo} already exists") try: # Secrets diff --git a/src/_nebari/provider/cicd/github.py b/src/_nebari/provider/cicd/github.py index b02c0bf32..f35875dc4 100644 --- a/src/_nebari/provider/cicd/github.py +++ b/src/_nebari/provider/cicd/github.py @@ -16,11 +16,14 @@ def github_request(url, method="GET", json=None, authenticate=True): auth = None if authenticate: + missing = [] for name in ("GITHUB_USERNAME", "GITHUB_TOKEN"): if os.environ.get(name) is None: - raise ValueError( - f"Environment variable={name} is required for GitHub automation" - ) + missing.append(name) + if len(missing) > 0: + raise ValueError( + f"Environment variable(s) required for GitHub automation - {', '.join(missing)}" + ) auth = requests.auth.HTTPBasicAuth( os.environ["GITHUB_USERNAME"], os.environ["GITHUB_TOKEN"] ) diff --git a/src/_nebari/subcommands/init.py b/src/_nebari/subcommands/init.py index ee5d8534e..0ec67119e 100644 --- a/src/_nebari/subcommands/init.py +++ b/src/_nebari/subcommands/init.py @@ -88,7 +88,7 @@ class InitInputs(schema.Base): namespace: typing.Optional[schema.namespace_pydantic] = "dev" auth_provider: AuthenticationEnum = AuthenticationEnum.password auth_auto_provision: bool = False - repository: typing.Union[str, None] = None + repository: typing.Optional[schema.github_url_pydantic] = None repository_auto_provision: bool = False ci_provider: CiEnum = CiEnum.none terraform_state: TerraformStateEnum = TerraformStateEnum.remote @@ -512,12 +512,17 @@ def init( auth_auto_provision: bool = typer.Option( False, ), - repository: GitRepoEnum = typer.Option( + repository: str = typer.Option( None, - help=f"options: {enum_to_list(GitRepoEnum)}", + help="Github repository URL to be initialized with --repository-auto-provision", + callback=typer_validate_regex( + schema.github_url_regex, + "Must be a fully qualified GitHub repository URL.", + ), ), repository_auto_provision: bool = typer.Option( False, + help="Initialize the GitHub repository provided by --repository (GitHub credentials required)", ), ci_provider: CiEnum = typer.Option( CiEnum.none, diff --git a/src/nebari/schema.py b/src/nebari/schema.py index 2d6b5f41e..c18488a96 100644 --- a/src/nebari/schema.py +++ b/src/nebari/schema.py @@ -17,6 +17,9 @@ email_regex = "^[^ @]+@[^ @]+\\.[^ @]+$" email_pydantic = pydantic.constr(regex=email_regex) +github_url_regex = "^(https://)?github.com/([^/]+)/([^/]+)/?$" +github_url_pydantic = pydantic.constr(regex=github_url_regex) + class Base(pydantic.BaseModel): ... diff --git a/tests/tests_unit/test_cli_init.py b/tests/tests_unit/test_cli_init.py index 0e336bf16..0cd0fe03d 100644 --- a/tests/tests_unit/test_cli_init.py +++ b/tests/tests_unit/test_cli_init.py @@ -74,38 +74,36 @@ def generate_test_data_test_cli_init_happy_path(): for domain_name in [f"{project_name}.example.com"]: for namespace in ["test-ns"]: for auth_provider in ["password", "Auth0", "GitHub"]: - for repository in ["github.com", "gitlab.com"]: - for ci_provider in [ - "none", - "github-actions", - "gitlab-ci", + for ci_provider in [ + "none", + "github-actions", + "gitlab-ci", + ]: + for terraform_state in [ + "local", + "remote", + "existing", ]: - for terraform_state in [ - "local", - "remote", - "existing", - ]: - for email in ["noreply@example.com"]: - for ( - kubernetes_version - ) in get_kubernetes_versions(provider) + [ - "latest" - ]: - test_data.append( - ( - provider, - region, - project_name, - domain_name, - namespace, - auth_provider, - repository, - ci_provider, - terraform_state, - email, - kubernetes_version, - ) + for email in ["noreply@example.com"]: + for ( + kubernetes_version + ) in get_kubernetes_versions(provider) + [ + "latest" + ]: + test_data.append( + ( + provider, + region, + project_name, + domain_name, + namespace, + auth_provider, + ci_provider, + terraform_state, + email, + kubernetes_version, ) + ) keys = [ "provider", @@ -114,7 +112,6 @@ def generate_test_data_test_cli_init_happy_path(): "domain_name", "namespace", "auth_provider", - "repository", "ci_provider", "terraform_state", "email", @@ -130,7 +127,6 @@ def test_cli_init_happy_path( domain_name: str, namespace: str, auth_provider: str, - repository: str, ci_provider: str, terraform_state: str, email: str, @@ -152,8 +148,6 @@ def test_cli_init_happy_path( namespace, "--auth-provider", auth_provider, - "--repository", - repository, # TODO: doesn't show up in the output anywhere, how do I verify this? "--ci-provider", ci_provider, "--terraform-state", diff --git a/tests/tests_unit/test_cli_init_repository.py b/tests/tests_unit/test_cli_init_repository.py new file mode 100644 index 000000000..6bc0d4e7d --- /dev/null +++ b/tests/tests_unit/test_cli_init_repository.py @@ -0,0 +1,240 @@ +import logging +import tempfile +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import requests.auth +import requests.exceptions +from typer.testing import CliRunner + +from _nebari.cli import create_cli +from _nebari.provider.cicd.github import GITHUB_BASE_URL + +runner = CliRunner() + +TEST_GITHUB_USERNAME = "test-nebari-github-user" +TEST_GITHUB_TOKEN = "nebari-super-secret" + +TEST_REPOSITORY_NAME = "nebari-test" + +DEFAULT_ARGS = [ + "init", + "local", + "--project-name", + "test", + "--repository-auto-provision", + "--repository", + f"https://github.com/{TEST_GITHUB_USERNAME}/{TEST_REPOSITORY_NAME}", +] + + +@patch( + "_nebari.provider.cicd.github.requests.get", + side_effect=lambda url, json, auth: mock_api_request( + "GET", + url, + json, + auth, + ), +) +@patch( + "_nebari.provider.cicd.github.requests.post", + side_effect=lambda url, json, auth: mock_api_request( + "POST", + url, + json, + auth, + ), +) +@patch( + "_nebari.provider.cicd.github.requests.put", + side_effect=lambda url, json, auth: mock_api_request( + "PUT", + url, + json, + auth, + ), +) +@patch( + "_nebari.initialize.git", + return_value=Mock( + is_git_repo=Mock(return_value=False), + initialize_git=Mock(return_value=True), + add_git_remote=Mock(return_value=True), + ), +) +def test_cli_init_repository_auto_provision( + _mock_requests_get, + _mock_requests_post, + _mock_requests_put, + _mock_git, + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("GITHUB_USERNAME", TEST_GITHUB_USERNAME) + monkeypatch.setenv("GITHUB_TOKEN", TEST_GITHUB_TOKEN) + + app = create_cli() + + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + result = runner.invoke(app, DEFAULT_ARGS + ["--output", tmp_file.resolve()]) + + assert 0 == result.exit_code + assert not result.exception + assert tmp_file.exists() is True + + +@patch( + "_nebari.provider.cicd.github.requests.get", + side_effect=lambda url, json, auth: mock_api_request( + "GET", url, json, auth, repo_exists=True + ), +) +@patch( + "_nebari.provider.cicd.github.requests.post", + side_effect=lambda url, json, auth: mock_api_request( + "POST", + url, + json, + auth, + ), +) +@patch( + "_nebari.provider.cicd.github.requests.put", + side_effect=lambda url, json, auth: mock_api_request( + "PUT", + url, + json, + auth, + ), +) +@patch( + "_nebari.initialize.git", + return_value=Mock( + is_git_repo=Mock(return_value=False), + initialize_git=Mock(return_value=True), + add_git_remote=Mock(return_value=True), + ), +) +def test_cli_init_repository_repo_exists( + _mock_requests_get, + _mock_requests_post, + _mock_requests_put, + _mock_git, + monkeypatch: pytest.MonkeyPatch, + capsys, + caplog, +): + monkeypatch.setenv("GITHUB_USERNAME", TEST_GITHUB_USERNAME) + monkeypatch.setenv("GITHUB_TOKEN", TEST_GITHUB_TOKEN) + + with capsys.disabled(): + caplog.set_level(logging.WARNING) + + app = create_cli() + + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + result = runner.invoke(app, DEFAULT_ARGS + ["--output", tmp_file.resolve()]) + + assert 0 == result.exit_code + assert not result.exception + assert tmp_file.exists() is True + assert "already exists" in caplog.text + + +def test_cli_init_error_repository_missing_env(monkeypatch: pytest.MonkeyPatch): + for e in [ + "GITHUB_USERNAME", + "GITHUB_TOKEN", + ]: + try: + monkeypatch.delenv(e) + except Exception as e: + pass + + app = create_cli() + + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + result = runner.invoke(app, DEFAULT_ARGS + ["--output", tmp_file.resolve()]) + + assert 1 == result.exit_code + assert result.exception + assert "Environment variable(s) required for GitHub automation" in str( + result.exception + ) + assert tmp_file.exists() is False + + +def test_cli_init_error_invalid_repo(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("GITHUB_USERNAME", TEST_GITHUB_USERNAME) + monkeypatch.setenv("GITHUB_TOKEN", TEST_GITHUB_TOKEN) + + app = create_cli() + + args = [ + "init", + "local", + "--project-name", + "test", + "--repository-auto-provision", + "--repository", + "https://notgithub.com", + ] + + with tempfile.TemporaryDirectory() as tmp: + tmp_file = Path(tmp).resolve() / "nebari-config.yaml" + assert tmp_file.exists() is False + + result = runner.invoke(app, args + ["--output", tmp_file.resolve()]) + + assert 2 == result.exit_code + assert result.exception + assert "repository URL" in str(result.stdout) + assert tmp_file.exists() is False + + +def mock_api_request( + method: str, + url: str, + json: str, + auth: requests.auth.HTTPBasicAuth, + repo_exists: bool = False, +): + response = Mock() + response.json = Mock(return_value={}) + response.raise_for_status = Mock(return_value=True) + if ( + url.startswith(GITHUB_BASE_URL) + and auth.username == TEST_GITHUB_USERNAME + and auth.password == TEST_GITHUB_TOKEN + ): + response.status_code = 200 + if ( + not repo_exists + and method == "GET" + and url.endswith(f"repos/{TEST_GITHUB_USERNAME}/{TEST_REPOSITORY_NAME}") + ): + response.status_code = 404 + response.raise_for_status.side_effect = requests.exceptions.HTTPError + elif method == "GET" and url.endswith( + f"repos/{TEST_GITHUB_USERNAME}/{TEST_REPOSITORY_NAME}/actions/secrets/public-key" + ): + response.json = Mock( + return_value={ + "key": "hBT5WZEj8ZoOv6TYJsfWq7MxTEQopZO5/IT3ZCVQPzs=", + "key_id": "012345678912345678", + } + ) + else: + response.status_code = 403 + response.raise_for_status.side_effect = requests.exceptions.HTTPError + return response