diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 90784195..73ca8d0d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -36,7 +36,8 @@ "github.vscode-github-actions", "ms-kubernetes-tools.vscode-kubernetes-tools", "ms-python.python", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "GitHub.copilot-chat" ], "settings": { "python": { diff --git a/.github/workflows/test-deployment.yaml b/.github/workflows/test-deployment.yaml index d2f0aca1..58856259 100644 --- a/.github/workflows/test-deployment.yaml +++ b/.github/workflows/test-deployment.yaml @@ -34,6 +34,9 @@ jobs: - name: get all resources if: failure() run: kubectl get all + - name: describe all resources + if: failure() + run: kubectl describe all - name: get logs if: failure() run: | diff --git a/manifests/base/runner-manager/main/deployment.yaml b/manifests/base/runner-manager/main/deployment.yaml index 0eaa2a66..786e5e4d 100644 --- a/manifests/base/runner-manager/main/deployment.yaml +++ b/manifests/base/runner-manager/main/deployment.yaml @@ -29,7 +29,7 @@ spec: livenessProbe: failureThreshold: 3 httpGet: - path: /_health + path: /_health/ port: http initialDelaySeconds: 5 periodSeconds: 10 diff --git a/runner_manager/dependencies.py b/runner_manager/dependencies.py index 73a3fefb..cdf2b0de 100644 --- a/runner_manager/dependencies.py +++ b/runner_manager/dependencies.py @@ -1,9 +1,12 @@ from functools import lru_cache +import httpx +from githubkit.config import Config from redis import Redis from redis_om import get_redis_connection from rq import Queue +from runner_manager.clients.github import GitHub from runner_manager.models.settings import Settings @@ -17,5 +20,19 @@ def get_redis() -> Redis: return get_redis_connection(url=get_settings().redis_om_url, decode_responses=True) +@lru_cache def get_queue() -> Queue: return Queue(connection=get_redis()) + + +@lru_cache() +def get_github() -> GitHub: + settings: Settings = get_settings() + config: Config = Config( + base_url=httpx.URL(str(settings.github_base_url)), + follow_redirects=True, + accept="*/*", + user_agent="runner-manager", + timeout=httpx.Timeout(30.0), + ) + return GitHub(settings.github_auth_strategy(), config=config) diff --git a/runner_manager/models/settings.py b/runner_manager/models/settings.py index 78438797..59e4959d 100644 --- a/runner_manager/models/settings.py +++ b/runner_manager/models/settings.py @@ -3,7 +3,8 @@ from typing import Any, Dict, List, Optional, Sequence import yaml -from pydantic import AnyHttpUrl, BaseSettings, RedisDsn, SecretStr +from githubkit import AppInstallationAuthStrategy, TokenAuthStrategy +from pydantic import AnyHttpUrl, BaseSettings, ConfigError, RedisDsn, SecretStr from runner_manager.models.runner_group import BaseRunnerGroup @@ -34,16 +35,59 @@ class LogLevel(str, Enum): class Settings(BaseSettings): name: str = "runner-manager" redis_om_url: Optional[RedisDsn] = None - github_base_url: Optional[AnyHttpUrl] = None api_key: Optional[SecretStr] = None allowed_hosts: Optional[Sequence[str]] = [ - "localhost", - "testserver", + "*", ] - github_webhook_secret: Optional[SecretStr] = None log_level: LogLevel = LogLevel.INFO runner_groups: List[BaseRunnerGroup] = [] + github_base_url: AnyHttpUrl = AnyHttpUrl("api.github.com", scheme="https") + github_webhook_secret: Optional[SecretStr] = None + github_token: Optional[SecretStr] = None + github_app_id: int | str = 0 + github_private_key: SecretStr = SecretStr("") + github_installation_id: int = 0 + github_client_id: Optional[str] = None + github_client_secret: SecretStr = SecretStr("") + + @property + def app_install(self) -> bool: + """ + Returns True if the github auth strategy should be an app install. + + To consider an app install user should define: + + - github_app_id + - github_private_key + - github_installation_id + """ + if ( + self.github_app_id + and self.github_private_key + and self.github_installation_id + ): + return True + return False + + def github_auth_strategy(self) -> AppInstallationAuthStrategy | TokenAuthStrategy: + """ + Returns the appropriate auth strategy for the current configuration. + """ + # prefer AppInstallationAuthStrategy over TokenAuthStrategy + if self.app_install: + return AppInstallationAuthStrategy( + app_id=self.github_app_id, + installation_id=self.github_installation_id, + private_key=self.github_private_key.get_secret_value(), + client_id=self.github_client_id, + client_secret=self.github_client_secret.get_secret_value(), + ) + elif self.github_token: + return TokenAuthStrategy(token=self.github_token.get_secret_value()) + else: + raise ConfigError("No github auth strategy configured") + class Config: smart_union = True env_file = ".env" diff --git a/tests/api/conftest.py b/tests/api/conftest.py index fa9828df..80d8df16 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -1,9 +1,12 @@ from datetime import timedelta +from functools import lru_cache import pytest from fastapi.testclient import TestClient from hypothesis import HealthCheck, settings +from runner_manager import Settings +from runner_manager.dependencies import get_settings from runner_manager.main import app settings.register_profile( @@ -16,10 +19,19 @@ settings.load_profile("api") +@lru_cache() +def api_settings(): + return Settings( + github_token="test", + github_base_url="http://localhost:4010", + ) + + @pytest.fixture(scope="function") def fastapp(): fastapp = app fastapp.dependency_overrides = {} + fastapp.dependency_overrides[get_settings] = api_settings return fastapp diff --git a/tests/api/test_dependencies.py b/tests/api/test_dependencies.py new file mode 100644 index 00000000..03503ae7 --- /dev/null +++ b/tests/api/test_dependencies.py @@ -0,0 +1,26 @@ +from typing import List + +from fastapi import APIRouter, Depends +from githubkit import Response +from githubkit.rest import OrgsOrgActionsRunnersGetResponse200, Runner + +from runner_manager.clients.github import GitHub +from runner_manager.dependencies import get_github + +router = APIRouter() + + +@router.get("/runners") +def list_runners(github: GitHub = Depends(get_github)) -> List[Runner]: + resp: Response[ + List[OrgsOrgActionsRunnersGetResponse200] + ] = github.rest.actions.list_self_hosted_runners_for_org(org="octo-org") + + return resp.parsed_data.runners + + +def test_github_dependency(client, fastapp): + fastapp.include_router(router, prefix="/api") + # fastapp.dependency_overrides[get_settings] = settings + runners: List[Runner] = client.get("/runners").json() + assert len(runners) >= 1 diff --git a/tests/unit/models/test_settings.py b/tests/unit/models/test_settings.py index 2e2931b5..2e6d99c3 100644 --- a/tests/unit/models/test_settings.py +++ b/tests/unit/models/test_settings.py @@ -3,6 +3,10 @@ import pytest import yaml +from githubkit import AppInstallationAuthStrategy, TokenAuthStrategy +from hypothesis import assume, given +from hypothesis import strategies as st +from pydantic import ConfigError from pytest import fixture from runner_manager.dependencies import get_settings @@ -73,6 +77,50 @@ def test_get_settings(config_file): Settings() +@given( + st.builds( + Settings, + github_app_id=st.integers(), + github_installation_id=st.integers(), + github_private_key=st.text(), + github_token=st.text(), + ) +) +def test_app_install(stsettings): + assume(stsettings.github_app_id) + assume(stsettings.github_installation_id) + assume(stsettings.github_private_key) + assert stsettings.app_install is True + assert isinstance(stsettings.github_auth_strategy(), AppInstallationAuthStrategy) + + +@given( + st.builds( + Settings, + github_app_id=st.just(0), + github_installation_id=st.just(0), + github_private_key=st.just(""), + ) +) +def test_token_auth_strategy(stsettings): + if stsettings.github_token: + assert isinstance(stsettings.github_auth_strategy(), TokenAuthStrategy) + + +@given( + st.builds( + Settings, + github_token=st.none(), + github_app_id=st.just(0), + github_installation_id=st.just(0), + github_private_key=st.just(""), + ) +) +def test_config_error(stsettings): + with pytest.raises(ConfigError): + stsettings.github_auth_strategy() + + def test_settings_runner_group(runner_group: RunnerGroup): settings = Settings(runner_groups=[runner_group]) assert settings.runner_groups == [runner_group]