Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .github/workflows/test_github_runner_manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion github-runner-manager/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
UserInfo,
)
from github_runner_manager.configuration.github import ( # noqa: F401
GitHubAppAuth,
GitHubAuth,
GitHubConfiguration,
GitHubOrg,
GitHubPath,
GitHubRepo,
GitHubTokenAuth,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions github-runner-manager/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
2 changes: 1 addition & 1 deletion github-runner-manager/tests/integration/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
79 changes: 79 additions & 0 deletions github-runner-manager/tests/integration/test_github_app_auth.py
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 11 additions & 3 deletions github-runner-manager/tests/unit/platform/test_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
),
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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"
Expand Down
Loading
Loading