diff --git a/.github/workflows/test_github_runner_manager.yaml b/.github/workflows/test_github_runner_manager.yaml index cdbecc563f..2a6f1239d0 100644 --- a/.github/workflows/test_github_runner_manager.yaml +++ b/.github/workflows/test_github_runner_manager.yaml @@ -22,6 +22,7 @@ jobs: - test_debug_ssh - test_metrics - test_planner_runner + - test_github_app_auth steps: - name: Checkout code uses: actions/checkout@v6.0.2 @@ -50,6 +51,10 @@ jobs: ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_8 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_8 }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_9 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_9 }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_10 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_10 }} + # GITHUB_APP_CLIENT_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY + ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_11 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_11 }} + ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_12 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_12 }} + ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_13 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_13 }} run: | tox -e integration -- -v --tb=native -s \ tests/integration/${{ matrix.test-module }}.py \ diff --git a/github-runner-manager/pyproject.toml b/github-runner-manager/pyproject.toml index 2655cb0e73..b45e993d92 100644 --- a/github-runner-manager/pyproject.toml +++ b/github-runner-manager/pyproject.toml @@ -3,7 +3,7 @@ [project] name = "github-runner-manager" -version = "0.17.1" +version = "0.18.0" authors = [ { name = "Canonical IS DevOps", email = "is-devops-team@canonical.com" }, ] diff --git a/github-runner-manager/src/github_runner_manager/configuration/__init__.py b/github-runner-manager/src/github_runner_manager/configuration/__init__.py index 264cdd90d2..e65508fd78 100644 --- a/github-runner-manager/src/github_runner_manager/configuration/__init__.py +++ b/github-runner-manager/src/github_runner_manager/configuration/__init__.py @@ -15,8 +15,11 @@ UserInfo, ) from github_runner_manager.configuration.github import ( # noqa: F401 + GitHubAppAuth, + GitHubAuth, GitHubConfiguration, GitHubOrg, GitHubPath, GitHubRepo, + GitHubTokenAuth, ) diff --git a/github-runner-manager/src/github_runner_manager/configuration/github.py b/github-runner-manager/src/github_runner_manager/configuration/github.py index 2fbdf6c1f7..1997619b09 100644 --- a/github-runner-manager/src/github_runner_manager/configuration/github.py +++ b/github-runner-manager/src/github_runner_manager/configuration/github.py @@ -8,15 +8,42 @@ from pydantic import BaseModel +class GitHubTokenAuth(BaseModel): + """GitHub personal access token authentication. + + Attributes: + token: GitHub personal access token. + """ + + token: str + + +class GitHubAppAuth(BaseModel): + """GitHub App installation authentication. + + Attributes: + app_client_id: GitHub App Client ID (or legacy numeric App ID). + installation_id: GitHub App installation ID. + private_key: PEM-encoded private key for the GitHub App. + """ + + app_client_id: str + installation_id: int + private_key: str + + +GitHubAuth: TypeAlias = GitHubTokenAuth | GitHubAppAuth + + class GitHubConfiguration(BaseModel): """GitHub configuration for the application. Attributes: - token: GitHub Token. + auth: GitHub authentication configuration. path: Information of the repository or organization. """ - token: str + auth: GitHubAuth path: "GitHubPath" diff --git a/github-runner-manager/src/github_runner_manager/github_client.py b/github-runner-manager/src/github_runner_manager/github_client.py index dd24304f68..9a4755db6f 100644 --- a/github-runner-manager/src/github_runner_manager/github_client.py +++ b/github-runner-manager/src/github_runner_manager/github_client.py @@ -7,7 +7,7 @@ import logging from datetime import datetime from time import perf_counter -from typing import Callable, ParamSpec, TypeVar +from typing import Any, Callable, ParamSpec, TypeVar import github from github import ( @@ -19,7 +19,14 @@ ) from typing_extensions import assert_never -from github_runner_manager.configuration.github import GitHubOrg, GitHubPath, GitHubRepo +from github_runner_manager.configuration.github import ( + GitHubAppAuth, + GitHubAuth, + GitHubOrg, + GitHubPath, + GitHubRepo, + GitHubTokenAuth, +) from github_runner_manager.manager.models import InstanceID, RunnerIdentity, RunnerMetadata from github_runner_manager.metrics.github_api import ( GITHUB_API_RATE_LIMIT_LIMIT, @@ -177,20 +184,29 @@ def _classify_github_metric_error(exc: Exception) -> str: class GithubClient: """GitHub API client.""" - def __init__(self, token: str): - """Instantiate the GiHub API client. + def __init__(self, auth: GitHubAuth): + """Instantiate the GitHub API client. Args: - token: GitHub personal token for API requests. + auth: GitHub authentication configuration for API requests. """ - self._token = token self._github = Github( - auth=github.Auth.Token(self._token), per_page=PAGE_SIZE, timeout=TIMEOUT_IN_SECS + auth=self._build_auth(auth), per_page=PAGE_SIZE, timeout=TIMEOUT_IN_SECS ) # PyGithub lacks methods for some endpoints (repo-level JIT config, get job by ID, # runner groups). Use the requester for raw REST calls that inherit auth and timeout. self._requester = self._github.requester + @staticmethod + def _build_auth(auth: GitHubAuth) -> Any: + """Build a PyGithub auth object from the configuration.""" + if isinstance(auth, GitHubTokenAuth): + return github.Auth.Token(auth.token) + if isinstance(auth, GitHubAppAuth): + app_auth = github.Auth.AppAuth(auth.app_client_id, auth.private_key) + return github.Auth.AppInstallationAuth(app_auth, auth.installation_id) + assert_never(auth) + @staticmethod def _build_runner( runner_id: int, diff --git a/github-runner-manager/src/github_runner_manager/platform/github_provider.py b/github-runner-manager/src/github_runner_manager/platform/github_provider.py index d745102787..9b34198a0c 100644 --- a/github-runner-manager/src/github_runner_manager/platform/github_provider.py +++ b/github-runner-manager/src/github_runner_manager/platform/github_provider.py @@ -80,7 +80,7 @@ def build( return cls( prefix=prefix, path=github_configuration.path, - github_client=GithubClient(github_configuration.token), + github_client=GithubClient(github_configuration.auth), ) def get_runner_health( diff --git a/github-runner-manager/tests/conftest.py b/github-runner-manager/tests/conftest.py index d2d6095acf..483618229f 100644 --- a/github-runner-manager/tests/conftest.py +++ b/github-runner-manager/tests/conftest.py @@ -137,3 +137,21 @@ def pytest_addoption(parser): help="Directory to store debug logs.", default=os.getenv("DEBUG_LOG_DIR", "/tmp/github-runner-manager-test-logs"), ) + parser.addoption( + "--github-app-client-id", + action="store", + help="GitHub App Client ID for App authentication integration tests.", + default=os.getenv("GITHUB_APP_CLIENT_ID"), + ) + parser.addoption( + "--github-app-installation-id", + action="store", + help="GitHub App installation ID for App authentication integration tests.", + default=os.getenv("GITHUB_APP_INSTALLATION_ID"), + ) + parser.addoption( + "--github-app-private-key", + action="store", + help="GitHub App PEM-encoded private key for App authentication integration tests.", + default=os.getenv("GITHUB_APP_PRIVATE_KEY"), + ) diff --git a/github-runner-manager/tests/integration/factories.py b/github-runner-manager/tests/integration/factories.py index 88402f749c..3ccac24fe9 100644 --- a/github-runner-manager/tests/integration/factories.py +++ b/github-runner-manager/tests/integration/factories.py @@ -214,7 +214,7 @@ def create_default_config( "allow_external_contributor": allow_external_contributor, "extra_labels": test_config.labels, "github_config": { - "token": github_config.token, + "auth": {"token": github_config.token}, "path": path_config, }, "service_config": { diff --git a/github-runner-manager/tests/integration/test_github_app_auth.py b/github-runner-manager/tests/integration/test_github_app_auth.py new file mode 100644 index 0000000000..2877f8dab1 --- /dev/null +++ b/github-runner-manager/tests/integration/test_github_app_auth.py @@ -0,0 +1,79 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for GitHub App authentication.""" + +import secrets + +import pytest + +from github_runner_manager.configuration.github import ( + GitHubAppAuth, + GitHubPath, + parse_github_path, +) +from github_runner_manager.github_client import GithubClient +from github_runner_manager.manager.models import InstanceID + + +@pytest.fixture(autouse=True, scope="module") +def openstack_cleanup(): + """Override the autouse openstack_cleanup fixture — this module has no OpenStack dependency.""" + yield + + +@pytest.fixture(scope="module") +def github_app_auth(pytestconfig: pytest.Config) -> GitHubAppAuth: + """Get GitHub App auth configuration, skip if credentials not provided.""" + app_client_id = pytestconfig.getoption("--github-app-client-id") + installation_id = pytestconfig.getoption("--github-app-installation-id") + private_key = pytestconfig.getoption("--github-app-private-key") + + if not all([app_client_id, installation_id, private_key]): + pytest.skip("GitHub App credentials not provided") + + return GitHubAppAuth( + app_client_id=app_client_id, + installation_id=int(installation_id), + private_key=private_key, + ) + + +@pytest.fixture(scope="module") +def github_app_client(github_app_auth: GitHubAppAuth) -> GithubClient: + """Create a GithubClient using GitHub App authentication.""" + return GithubClient(auth=github_app_auth) + + +@pytest.fixture(scope="module") +def github_path(pytestconfig: pytest.Config) -> GitHubPath: + """Get the GitHub path from test configuration.""" + path_str = pytestconfig.getoption("--github-repository") + if not path_str: + pytest.skip("GitHub repository path not provided") + return parse_github_path(path_str, runner_group="default") + + +def test_get_jit_token_with_github_app_auth( + github_app_client: GithubClient, github_path: GitHubPath +) -> None: + """ + arrange: GithubClient created with GitHubAppAuth credentials. + act: Request a JIT config token to register a runner. + assert: Token is returned and runner is created, then clean up. + """ + prefix = "test-app-auth" + instance_id = InstanceID(prefix=prefix, suffix=secrets.token_hex(6)) + + runner = None + try: + jit_token, runner = github_app_client.get_runner_registration_jittoken( + github_path, instance_id=instance_id, labels=[prefix] + ) + + assert jit_token, "JIT config token should be non-empty" + assert runner.id > 0 + assert runner.identity.instance_id == instance_id + finally: + if runner is not None: + github_app_client.delete_runner(github_path, runner.id) diff --git a/github-runner-manager/tests/unit/platform/test_factory.py b/github-runner-manager/tests/unit/platform/test_factory.py index 6c36eb7a5e..0ba7f5a82e 100644 --- a/github-runner-manager/tests/unit/platform/test_factory.py +++ b/github-runner-manager/tests/unit/platform/test_factory.py @@ -3,10 +3,13 @@ """Test for the platform factory module.""" -from unittest.mock import MagicMock - import pytest +from github_runner_manager.configuration.github import ( + GitHubConfiguration, + GitHubOrg, + GitHubTokenAuth, +) from github_runner_manager.platform.factory import platform_factory from github_runner_manager.platform.github_provider import GitHubRunnerPlatform @@ -32,7 +35,12 @@ def test_platform_factory_invalid_configurations(github_config, expected_error): "github_config, expected_platform", [ pytest.param( - MagicMock(token="fake-token"), GitHubRunnerPlatform, id="GitHub configuration" + GitHubConfiguration( + auth=GitHubTokenAuth(token="fake-token"), + path=GitHubOrg(org="canonical", group="default"), + ), + GitHubRunnerPlatform, + id="GitHub configuration", ), ], ) diff --git a/github-runner-manager/tests/unit/platform/test_github_provider.py b/github-runner-manager/tests/unit/platform/test_github_provider.py index 31dd73ae13..9fb0fa9061 100644 --- a/github-runner-manager/tests/unit/platform/test_github_provider.py +++ b/github-runner-manager/tests/unit/platform/test_github_provider.py @@ -8,6 +8,12 @@ import pytest +from github_runner_manager.configuration.github import ( + GitHubAppAuth, + GitHubConfiguration, + GitHubOrg, + GitHubTokenAuth, +) from github_runner_manager.github_client import GithubClient from github_runner_manager.manager.models import InstanceID, RunnerIdentity, RunnerMetadata from github_runner_manager.platform.github_provider import ( @@ -22,6 +28,37 @@ from github_runner_manager.types_.github import GitHubRunnerStatus, SelfHostedRunner +@pytest.mark.parametrize( + "auth", + [ + pytest.param(GitHubTokenAuth(token="token"), id="token-auth"), + pytest.param( + GitHubAppAuth( + app_client_id="Iv23liExample", installation_id=2, private_key="private-key" + ), + id="app-auth", + ), + ], +) +def test_build_uses_github_configuration_auth(monkeypatch: pytest.MonkeyPatch, auth): + """ + arrange: A GitHub configuration with either PAT or GitHub App auth. + act: Build GitHubRunnerPlatform. + assert: GithubClient is constructed with the auth object. + """ + github_client_ctor = MagicMock(return_value=MagicMock(spec=GithubClient)) + monkeypatch.setattr( + "github_runner_manager.platform.github_provider.GithubClient", github_client_ctor + ) + + config = GitHubConfiguration(auth=auth, path=GitHubOrg(org="canonical", group="default")) + + platform = GitHubRunnerPlatform.build(prefix="unit-0", github_configuration=config) + + assert isinstance(platform, GitHubRunnerPlatform) + github_client_ctor.assert_called_once_with(auth) + + def _params_test_get_runner_health(): """Parameterized data for test_get_runner_health.""" prefix = "unit-0" diff --git a/github-runner-manager/tests/unit/test_config.py b/github-runner-manager/tests/unit/test_config.py index cb91fb4fe0..57e8b4cf1e 100644 --- a/github-runner-manager/tests/unit/test_config.py +++ b/github-runner-manager/tests/unit/test_config.py @@ -13,8 +13,10 @@ from src.github_runner_manager.configuration import ( ApplicationConfiguration, Flavor, + GitHubAppAuth, GitHubConfiguration, GitHubOrg, + GitHubTokenAuth, Image, ProxyConfig, RunnerCombination, @@ -33,10 +35,11 @@ - label1 - label2 github_config: + auth: + token: githubtoken path: group: group org: canonical - token: githubtoken runner_configuration: combinations: - base_virtual_machines: 1 @@ -90,7 +93,8 @@ def app_config_fixture() -> ApplicationConfiguration: name="app_name", extra_labels=["label1", "label2"], github_config=GitHubConfiguration( - token="githubtoken", path=GitHubOrg(org="canonical", group="group") + auth=GitHubTokenAuth(token="githubtoken"), + path=GitHubOrg(org="canonical", group="group"), ), service_config=SupportServiceConfig( proxy_config=ProxyConfig( @@ -149,6 +153,31 @@ def app_config_fixture() -> ApplicationConfiguration: ) +@pytest.mark.parametrize( + "auth", + [ + pytest.param(GitHubTokenAuth(token="githubtoken"), id="token-auth"), + pytest.param( + GitHubAppAuth( + app_client_id="Iv23liExample", + installation_id=456, + private_key="-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ), + id="app-auth", + ), + ], +) +def test_github_auth_models_validate(auth): + """ + arrange: A GitHub auth model. + act: Build GitHubConfiguration. + assert: The auth model is preserved. + """ + config = GitHubConfiguration(auth=auth, path=GitHubOrg(org="canonical", group="group")) + + assert config.auth == auth + + def test_configuration_roundtrip(app_config: ApplicationConfiguration): """ arrange: A sample ApplicationConfiguration. diff --git a/github-runner-manager/tests/unit/test_github_client.py b/github-runner-manager/tests/unit/test_github_client.py index 4405babd4c..e5993f3dd9 100644 --- a/github-runner-manager/tests/unit/test_github_client.py +++ b/github-runner-manager/tests/unit/test_github_client.py @@ -4,7 +4,7 @@ import secrets from collections import namedtuple from datetime import datetime, timezone -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import pytest from github import ( @@ -15,7 +15,12 @@ ) from prometheus_client import REGISTRY -from github_runner_manager.configuration.github import GitHubOrg, GitHubRepo +from github_runner_manager.configuration.github import ( + GitHubAppAuth, + GitHubOrg, + GitHubRepo, + GitHubTokenAuth, +) from github_runner_manager.github_client import ( GithubClient, GithubRunnerNotFoundError, @@ -122,7 +127,7 @@ def job_stats_fixture() -> JobStatsRawData: @pytest.fixture(name="github_client") def github_client_fixture(job_stats_raw: JobStatsRawData) -> GithubClient: """Create a GithubClient object with a mocked PyGithub object.""" - gh_client = GithubClient("token") + gh_client = GithubClient(GitHubTokenAuth(token="token")) gh_client._github = MagicMock() gh_client._requester = MagicMock() gh_client._requester.rate_limiting = (4999, 5000) @@ -147,6 +152,61 @@ def github_client_fixture(job_stats_raw: JobStatsRawData) -> GithubClient: return gh_client +@pytest.mark.parametrize( + "auth_config, expected_calls, expected_github_auth", + [ + pytest.param( + GitHubTokenAuth(token="test-token"), + [call.Token("test-token")], + "token-auth", + id="token-auth", + ), + pytest.param( + GitHubAppAuth( + app_client_id="Iv23liExample", + installation_id=456, + private_key="-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ), + [ + call.AppAuth( + "Iv23liExample", + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ), + call.AppInstallationAuth("app-auth", 456), + ], + "installation-auth", + id="app-auth", + ), + ], +) +def test_github_client_initializes_pygithub_auth( + monkeypatch: pytest.MonkeyPatch, + auth_config, + expected_calls, + expected_github_auth: str, +): + """ + arrange: A GitHub auth configuration and mocked PyGithub auth constructors. + act: Construct GithubClient. + assert: The matching PyGithub auth class is used. + """ + auth_mock = MagicMock() + auth_mock.Token.return_value = "token-auth" + auth_mock.AppAuth.return_value = "app-auth" + auth_mock.AppInstallationAuth.return_value = "installation-auth" + github_ctor = MagicMock() + github_ctor.return_value.requester = MagicMock() + monkeypatch.setattr("github_runner_manager.github_client.github.Auth", auth_mock) + monkeypatch.setattr("github_runner_manager.github_client.Github", github_ctor) + + client = GithubClient(auth_config) + + assert client._github is github_ctor.return_value + assert client._requester is github_ctor.return_value.requester + assert auth_mock.mock_calls == expected_calls + assert github_ctor.call_args.kwargs["auth"] == expected_github_auth + + def _mock_multiple_pages_for_job_response( github_client: GithubClient, job_stats_raw: JobStatsRawData, include_runner: bool = True ): diff --git a/github-runner-manager/tox.ini b/github-runner-manager/tox.ini index 3353bf3246..195eb39a70 100644 --- a/github-runner-manager/tox.ini +++ b/github-runner-manager/tox.ini @@ -123,6 +123,9 @@ passenv = OS_PASSWORD OS_NETWORK OS_REGION_NAME + GITHUB_APP_CLIENT_ID + GITHUB_APP_INSTALLATION_ID + GITHUB_APP_PRIVATE_KEY commands = pytest {posargs:{[vars]tst_path}integration} \ -v --tb native -s