diff --git a/.github/workflows/integration_test.yaml b/.github/workflows/integration_test.yaml index 1c6ad3eb2c..3a19d92745 100644 --- a/.github/workflows/integration_test.yaml +++ b/.github/workflows/integration_test.yaml @@ -20,9 +20,9 @@ jobs: juju-channel: 3.6/stable provider: lxd test-tox-env: integration - modules: '["test_multi_unit_same_machine", "test_charm_fork_path_change", "test_charm_no_runner", "test_charm_upgrade"]' - # INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT, OS_* are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_ - # mapping. See CONTRIBUTING.md for more details. + modules: '["test_multi_unit_same_machine", "test_charm_no_runner", "test_charm_upgrade"]' + # Sensitive env vars are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_; + # see CONTRIBUTING.md for the slot-to-var mapping. extra-arguments: | -m=openstack \ --log-format="%(asctime)s %(levelname)s %(message)s" \ @@ -35,6 +35,7 @@ jobs: --openstack-no-proxy="${{ vars.INTEGRATION_TEST_OPENSTACK_NO_PROXY }}" \ --openstack-flavor-name="${{ vars.INTEGRATION_TEST_OPENSTACK_FLAVOR_NAME }}" \ --openstack-image-id="${{ vars.INTEGRATION_TEST_IMAGE_ID }}" \ + --github-app-client-id="${{ vars.INTEGRATION_TEST_GITHUB_APP_CLIENT_ID }}" \ --dockerhub-mirror="${{ vars.INTEGRATION_TEST_DOCKERHUB_MIRROR }}" self-hosted-runner: true self-hosted-runner-label: pfe-ci @@ -49,8 +50,8 @@ jobs: provider: lxd test-tox-env: integration modules: '["test_prometheus_metrics"]' - # INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT, OS_* are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_ - # mapping. See CONTRIBUTING.md for more details. + # Sensitive env vars are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_; + # see CONTRIBUTING.md for the slot-to-var mapping. extra-arguments: | -m=openstack \ --log-format="%(asctime)s %(levelname)s %(message)s" \ @@ -63,6 +64,7 @@ jobs: --openstack-no-proxy="${{ vars.INTEGRATION_TEST_OPENSTACK_NO_PROXY }}" \ --openstack-flavor-name="${{ vars.INTEGRATION_TEST_OPENSTACK_FLAVOR_NAME }}" \ --openstack-image-id="${{ vars.INTEGRATION_TEST_IMAGE_ID }}" \ + --github-app-client-id="${{ vars.INTEGRATION_TEST_GITHUB_APP_CLIENT_ID }}" \ --dockerhub-mirror="${{ vars.INTEGRATION_TEST_DOCKERHUB_MIRROR }}" self-hosted-runner: true self-hosted-runner-label: pfe-ci @@ -91,6 +93,7 @@ jobs: --openstack-no-proxy="${{ vars.INTEGRATION_TEST_OPENSTACK_NO_PROXY }}" \ --openstack-flavor-name="${{ vars.INTEGRATION_TEST_OPENSTACK_FLAVOR_NAME }}" \ --openstack-image-id="${{ vars.INTEGRATION_TEST_IMAGE_ID }}" \ + --github-app-client-id="${{ vars.INTEGRATION_TEST_GITHUB_APP_CLIENT_ID }}" \ --dockerhub-mirror="${{ vars.INTEGRATION_TEST_DOCKERHUB_MIRROR }}" \ --base="${{ matrix.base }}" self-hosted-runner: true diff --git a/.github/workflows/test_github_runner_manager.yaml b/.github/workflows/test_github_runner_manager.yaml index cdbecc563f..449f8c06a1 100644 --- a/.github/workflows/test_github_runner_manager.yaml +++ b/.github/workflows/test_github_runner_manager.yaml @@ -36,12 +36,11 @@ jobs: - name: Run integration tests - ${{ matrix.test-module }} working-directory: ./github-runner-manager/ env: - # GitHub configuration - # INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT + # Sensitive env vars are passed through INTEGRATION_TEST_SECRET_ENV_VALUE_; + # see CONTRIBUTING.md for the slot-to-var mapping. + ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_1 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_1 }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_2 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_2 }} - # OpenStack configuration - # OS_AUTH_URL, OS_PROJECT_DOMAIN_NAME, OS_PROJECT_NAME, OS_USER_DOMAIN_NAME, OS_USERNAME, OS_PASSWORD, OS_NETWORK, OS_REGION_NAME ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_3 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_3 }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_4 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_4 }} ${{ vars.INTEGRATION_TEST_SECRET_ENV_NAME_5 }}: ${{ secrets.INTEGRATION_TEST_SECRET_ENV_VALUE_5 }} @@ -50,6 +49,8 @@ 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 }} + ${{ 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 }} run: | tox -e integration -- -v --tb=native -s \ tests/integration/${{ matrix.test-module }}.py \ diff --git a/github-runner-manager/tests/conftest.py b/github-runner-manager/tests/conftest.py index d2d6095acf..1f949bf7e4 100644 --- a/github-runner-manager/tests/conftest.py +++ b/github-runner-manager/tests/conftest.py @@ -13,21 +13,21 @@ def pytest_addoption(parser): parser: Pytest parser. """ parser.addoption( - "--github-token", + "--github-repository", action="store", - help="GitHub personal access token for integration tests.", - default=os.getenv("INTEGRATION_TOKEN"), + help="The GitHub repository in / format for integration tests.", ) parser.addoption( - "--github-repository", + "--github-app-client-id", action="store", - help="The GitHub repository in / format for integration tests.", + help="GitHub App Client ID for integration tests.", + default=os.getenv("GITHUB_APP_CLIENT_ID"), ) parser.addoption( - "--github-token-alt", + "--github-app-installation-id", action="store", - help="Alternate GitHub token from a different user for fork testing.", - default=os.getenv("INTEGRATION_TOKEN_ALT"), + help="GitHub App installation ID for integration tests.", + default=os.getenv("GITHUB_APP_INSTALLATION_ID"), ) parser.addoption( "--openstack-auth-url", diff --git a/github-runner-manager/tests/integration/conftest.py b/github-runner-manager/tests/integration/conftest.py index 541e2dd0e8..81257d7b22 100644 --- a/github-runner-manager/tests/integration/conftest.py +++ b/github-runner-manager/tests/integration/conftest.py @@ -4,9 +4,10 @@ """Fixtures for github-runner-manager integration tests.""" import logging +import os import time from pathlib import Path -from typing import Generator +from typing import Generator, cast import openstack import pytest @@ -35,7 +36,12 @@ def test_config(pytestconfig: pytest.Config) -> TestConfig: @pytest.fixture(scope="module") def github_config(pytestconfig: pytest.Config) -> GitHubConfig: - """Get GitHub configuration from pytest options or environment. + """Get GitHub configuration. + + The token and private key are read from the environment only + (``INTEGRATION_TOKEN`` and ``GITHUB_APP_PRIVATE_KEY``). The repository path + and GitHub App client/installation IDs come from pytest options; the app + ID options fall back to environment variables (see ``pytest_addoption``). Args: pytestconfig: Pytest configuration object. @@ -44,21 +50,32 @@ def github_config(pytestconfig: pytest.Config) -> GitHubConfig: GitHub configuration object. Raises: - pytest.fail: If neither --github-token option nor INTEGRATION_TOKEN - environment variable is set. + pytest.fail: If INTEGRATION_TOKEN or the GitHub repository is not set. """ - token = pytestconfig.getoption("--github-token") - alt_token = pytestconfig.getoption("--github-token-alt", None) + token = os.getenv("INTEGRATION_TOKEN") path = pytestconfig.getoption("--github-repository") + app_client_id = pytestconfig.getoption("--github-app-client-id") or None + installation_id_raw = pytestconfig.getoption("--github-app-installation-id") or None + private_key = os.getenv("GITHUB_APP_PRIVATE_KEY") or None - if not token or not alt_token or not path: + if not token or not path: pytest.fail( - "GitHub configuration not provided. Use --github-token, --github-token-alt, and " - "--github-repository options or set INTEGRATION_TOKEN, INTEGRATION_TOKEN_ALT, and " - "GITHUB_REPOSITORY environment variables." + "GitHub configuration not provided. Set INTEGRATION_TOKEN and use " + "--github-repository." + ) + + if all((app_client_id, installation_id_raw, private_key)): + logger.info("Using GitHub App authentication for integration tests") + return GitHubConfig( + token=token, + path=path, + app_client_id=app_client_id, + installation_id=int(cast(str, installation_id_raw)), + private_key=private_key, ) - return GitHubConfig(token=token, alt_token=alt_token, path=path) + logger.info("Using PAT authentication for integration tests") + return GitHubConfig(token=token, path=path) @pytest.fixture(scope="module") diff --git a/github-runner-manager/tests/integration/factories.py b/github-runner-manager/tests/integration/factories.py index 3ccac24fe9..d060b63844 100644 --- a/github-runner-manager/tests/integration/factories.py +++ b/github-runner-manager/tests/integration/factories.py @@ -25,13 +25,23 @@ class GitHubConfig: Attributes: token: GitHub personal access token. - alt_token: Alternate GitHub personal access token for external contributor. path: GitHub path in / or format. + app_client_id: GitHub App Client ID. + installation_id: GitHub App installation ID. + private_key: GitHub App PEM-encoded private key. + has_app_auth: Whether GitHub App authentication credentials are configured. """ - token: str - alt_token: str + token: str = field(repr=False) path: str + app_client_id: str | None = None + installation_id: int | None = None + private_key: str | None = field(default=None, repr=False) + + @property + def has_app_auth(self) -> bool: + """Whether GitHub App authentication credentials are configured.""" + return all((self.app_client_id, self.installation_id, self.private_key)) @dataclass @@ -54,7 +64,7 @@ class OpenStackConfig: auth_url: str project: str username: str - password: str + password: str = field(repr=False) network: str region_name: str = "RegionOne" user_domain_name: str = "Default" @@ -166,7 +176,6 @@ def create_default_config( if github_config is None: github_config = GitHubConfig( token="ghp_test_token_1234567890abcdef", - alt_token="ghp_test_alt_token_1234567890abcdef", path="test-org", ) @@ -214,7 +223,15 @@ def create_default_config( "allow_external_contributor": allow_external_contributor, "extra_labels": test_config.labels, "github_config": { - "auth": {"token": github_config.token}, + "auth": ( + { + "app_client_id": github_config.app_client_id, + "installation_id": github_config.installation_id, + "private_key": github_config.private_key, + } + if github_config.has_app_auth + else {"token": github_config.token} + ), "path": path_config, }, "service_config": { diff --git a/github-runner-manager/tests/unit/test_integration_factories.py b/github-runner-manager/tests/unit/test_integration_factories.py new file mode 100644 index 0000000000..a33d35b442 --- /dev/null +++ b/github-runner-manager/tests/unit/test_integration_factories.py @@ -0,0 +1,45 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for integration test factories.""" + +from tests.integration.factories import GitHubConfig, create_default_config + + +def test_create_default_config_uses_token_auth_by_default(): + """ + arrange: A GitHub test config without GitHub App credentials. + act: Build the integration test application config. + assert: The GitHub auth block uses token auth. + """ + config = create_default_config( + github_config=GitHubConfig( + token="ghp_test_token_1234567890abcdef", + path="canonical/example", + ) + ) + + assert config["github_config"]["auth"] == {"token": "ghp_test_token_1234567890abcdef"} + + +def test_create_default_config_uses_github_app_auth_when_available(): + """ + arrange: A GitHub test config with complete GitHub App credentials. + act: Build the integration test application config. + assert: The GitHub auth block uses GitHub App auth. + """ + config = create_default_config( + github_config=GitHubConfig( + token="ghp_test_token_1234567890abcdef", + path="canonical/example", + app_client_id="Iv23liExample", + installation_id=456, + private_key="-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ) + ) + + assert config["github_config"]["auth"] == { + "app_client_id": "Iv23liExample", + "installation_id": 456, + "private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + } diff --git a/github-runner-manager/tox.ini b/github-runner-manager/tox.ini index 195eb39a70..a7b7accb8a 100644 --- a/github-runner-manager/tox.ini +++ b/github-runner-manager/tox.ini @@ -114,7 +114,6 @@ passenv = PYTHONPATH GITHUB_REPOSITORY INTEGRATION_TOKEN - INTEGRATION_TOKEN_ALT OS_AUTH_URL OS_PROJECT_DOMAIN_NAME OS_PROJECT_NAME diff --git a/tests/conftest.py b/tests/conftest.py index cdfa69112e..dc265ae22e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -179,12 +179,6 @@ def pytest_addoption(parser: Parser): help="The GitHub App installation ID for GitHub App authentication testing.", default=os.environ.get("GITHUB_APP_INSTALLATION_ID"), ) - parser.addoption( - "--github-app-private-key", - action="store", - help="The GitHub App PEM-encoded private key for GitHub App authentication testing.", - default=os.environ.get("GITHUB_APP_PRIVATE_KEY"), - ) parser.addoption( "--keep-models", action="store_true", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ab902812a7..4ced2a443c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,6 +5,7 @@ import json import logging +import os import random import re import secrets @@ -12,7 +13,6 @@ import textwrap from dataclasses import dataclass from pathlib import Path -from time import sleep from typing import Any, Generator, Iterator, Optional, cast import jubilant @@ -33,7 +33,6 @@ LABELS_CONFIG_NAME, OPENSTACK_FLAVOR_CONFIG_NAME, OPENSTACK_NETWORK_CONFIG_NAME, - PATH_CONFIG_NAME, USE_APROXY_CONFIG_NAME, ) from tests.integration.helpers.common import ( @@ -319,6 +318,21 @@ def github_config(pytestconfig: pytest.Config) -> GitHubConfig: "path of / or / format." ) + app_client_id = pytestconfig.getoption("--github-app-client-id") or None + installation_id_raw = pytestconfig.getoption("--github-app-installation-id") or None + private_key = os.getenv("GITHUB_APP_PRIVATE_KEY") or None + + if all((app_client_id, installation_id_raw, private_key)): + logging.info("Using GitHub App authentication for integration tests") + return GitHubConfig( + token=random_token, + path=path, + app_client_id=app_client_id, + installation_id=int(cast(str, installation_id_raw)), + private_key=private_key, + ) + + logging.info("Using PAT authentication for integration tests") return GitHubConfig(token=random_token, path=path) @@ -776,76 +790,6 @@ def github_repository(github_client: Github, github_config: GitHubConfig) -> Rep return github_client.get_repo(github_config.path) -@pytest.fixture(scope="module") -def forked_github_repository( - github_repository: Repository, -) -> Repository: - """Create a fork for a GitHub repository.""" - # After fork creation, the repository workflow run must be enabled manually. Otherwise, a 404 - # on the workflow get API will be returned. - forked_repository = github_repository.create_fork(name=f"test-{github_repository.name}") - - # Wait for repo to be ready - for _ in range(10): - try: - sleep(10) - forked_repository.get_branches() - break - except GithubException: - pass - else: - assert False, "timed out whilst waiting for repository creation" - - return forked_repository - - # Parallel runs of this test module is allowed. Therefore, the forked repo is not removed. - - -@pytest.fixture(scope="module") -def forked_github_branch( - github_repository: Repository, forked_github_repository: Repository -) -> Iterator[Branch]: - """Create a new forked branch for testing.""" - branch_name = f"test/{secrets.token_hex(4)}" - - main_branch = forked_github_repository.get_branch(github_repository.default_branch) - branch_ref = forked_github_repository.create_git_ref( - ref=f"refs/heads/{branch_name}", sha=main_branch.commit.sha - ) - - for _ in range(10): - try: - branch = forked_github_repository.get_branch(branch_name) - break - except GithubException as err: - if err.status == 404: - sleep(5) - continue - raise - else: - assert ( - False - ), "Failed to get created branch in fork repo, the issue with GitHub or network." - - yield branch - - branch_ref.delete() - - -@pytest.fixture(scope="module") -def app_with_forked_repo( - juju: jubilant.Juju, basic_app: str, forked_github_repository: Repository -) -> str: - """Application with no runner on a forked repo. - - Test should ensure it returns with the application in a good state and has - one runner. - """ - juju.config(basic_app, values={PATH_CONFIG_NAME: forked_github_repository.full_name}) - - return basic_app - - @pytest.fixture(scope="module", name="test_github_branch") def test_github_branch_fixture(github_repository: Repository) -> Iterator[Branch]: """Create a new branch for testing, from latest commit in current branch.""" diff --git a/tests/integration/test_charm_fork_path_change.py b/tests/integration/test_charm_fork_path_change.py deleted file mode 100644 index 566dd1cfe6..0000000000 --- a/tests/integration/test_charm_fork_path_change.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Integration tests for github-runner charm with a fork repo. - -Tests a path change in the repo. -""" - -import logging - -import jubilant -import pytest -from github.Repository import Repository - -from charm_state import PATH_CONFIG_NAME -from tests.integration.conftest import GitHubConfig -from tests.integration.helpers.common import wait_for_runner_ready -from tests.integration.helpers.openstack import OpenStackInstanceHelper - -logger = logging.getLogger(__name__) - - -@pytest.mark.skip( - reason=( - "Fork creation fails under the current fine-grained PAT (scoped to canonical org, " - "fork lands in a personal namespace). Will be resolved in an upcoming PR." - ) -) -@pytest.mark.openstack -@pytest.mark.abort_on_fail -def test_path_config_change( - juju: jubilant.Juju, - app_with_forked_repo: str, - github_repository: Repository, - github_config: GitHubConfig, - instance_helper: OpenStackInstanceHelper, -) -> None: - """ - arrange: A working application with one runner in a forked repository. - act: Change the path configuration to the main repository and reconcile runners. - assert: No runners connected to the forked repository and one runner in the main repository. - """ - logger.info("test_path_config_change") - juju.wait( - lambda status: jubilant.all_active(status, app_with_forked_repo), - delay=10, - timeout=10 * 60, - ) - - logger.info("Ensure there is a runner (this calls reconcile)") - instance_helper.ensure_charm_has_runner(app_with_forked_repo) - - juju.config(app_with_forked_repo, values={PATH_CONFIG_NAME: github_config.path}) - - logger.info("Reconciling (again)") - wait_for_runner_ready(juju, app_with_forked_repo) - - unit_name = f"{app_with_forked_repo}/0" - runner_names = instance_helper.get_runner_names(unit_name) - logger.info("runners: %s", runner_names) - assert len(runner_names) == 1 - runner_name = runner_names[0] - - runners_in_repo = github_repository.get_self_hosted_runners() - logger.info("runners in github repo: %s", list(runners_in_repo)) - - runner_in_repo_with_same_name = tuple( - filter(lambda runner: runner.name == runner_name, runners_in_repo) - ) - - assert len(runner_in_repo_with_same_name) == 1 diff --git a/tests/integration/test_charm_upgrade.py b/tests/integration/test_charm_upgrade.py index 4960c56619..8fd92aac01 100644 --- a/tests/integration/test_charm_upgrade.py +++ b/tests/integration/test_charm_upgrade.py @@ -31,15 +31,6 @@ pytestmark = pytest.mark.openstack -@pytest.mark.skip( - reason=( - "latest/edge charm predates token-secret-id and openstack-clouds-yaml-secret-id " - "config options. Falling back to the plaintext token/openstack-clouds-yaml options " - "is not acceptable because jubilant logs deploy config, which leaks the token. " - "Re-enable once a release containing the secret-id options has been promoted to " - "latest/edge." - ) -) def test_charm_upgrade( juju: jubilant.Juju, deployment_context: DeploymentContext, diff --git a/tests/integration/test_e2e.py b/tests/integration/test_e2e.py index 5fb3f81c35..86619b66e1 100644 --- a/tests/integration/test_e2e.py +++ b/tests/integration/test_e2e.py @@ -1,19 +1,14 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""End-to-end integration test. +"""End-to-end integration test.""" -Uses GitHub App authentication when credentials are provided, falling back to PAT. -""" - -import logging from typing import Iterator import pytest from github.Branch import Branch from github.Repository import Repository -from tests.integration.conftest import GitHubConfig from tests.integration.helpers.common import ( DISPATCH_E2E_TEST_RUN_WORKFLOW_FILENAME, dispatch_workflow, @@ -21,27 +16,6 @@ from tests.integration.helpers.openstack import OpenStackInstanceHelper -@pytest.fixture(scope="module") -def github_config(pytestconfig: pytest.Config, github_config: GitHubConfig) -> GitHubConfig: - """Override github_config to prefer GitHub App auth when credentials are available.""" - app_client_id = pytestconfig.getoption("--github-app-client-id") or None - installation_id_raw = pytestconfig.getoption("--github-app-installation-id") or None - private_key = pytestconfig.getoption("--github-app-private-key") or None - - if not all((app_client_id, installation_id_raw, private_key)): - logging.info("Using PAT authentication for e2e test") - return github_config - - logging.info("Using GitHub App authentication for e2e test") - return GitHubConfig( - token=github_config.token, - path=github_config.path, - app_client_id=app_client_id, - installation_id=int(installation_id_raw), # type: ignore[arg-type] - private_key=private_key, - ) - - @pytest.fixture(scope="function", name="app") def app_fixture( basic_app: str, diff --git a/tox.ini b/tox.ini index 572b6f811e..6e9b06c8fa 100644 --- a/tox.ini +++ b/tox.ini @@ -112,7 +112,6 @@ description = Run integration tests pass_env = PYTEST_ADDOPTS INTEGRATION_TOKEN - INTEGRATION_TOKEN_ALT OS_AUTH_URL OS_PROJECT_DOMAIN_NAME OS_PROJECT_NAME