Skip to content

Commit

Permalink
Routes and dependencies for GitHub CI app integration
Browse files Browse the repository at this point in the history
  • Loading branch information
fajpunk committed Jul 9, 2024
1 parent d79e4a7 commit 0e0936f
Show file tree
Hide file tree
Showing 14 changed files with 2,012 additions and 17 deletions.
62 changes: 54 additions & 8 deletions src/mobu/dependencies/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,73 @@
import yaml

from ..models.github import GitHubConfig
from ..models.user import User
from ..services.github_ci.ci_manager import CiManager
from .context import ContextDependency

__all__ = ["GitHubConfigDependency", "CiManagerDependency"]


class GitHubConfigDependency:
"""Holds the config for GitHub app integration, loaded from a file."""

def __init__(self) -> None:
self._config: GitHubConfig | None = None
self.config: GitHubConfig

def __call__(self) -> GitHubConfig:
return self.config

@property
def config(self) -> GitHubConfig:
if not self._config:
raise RuntimeError("GitHubConfigDependency not initialized")
return self._config

def initialize(self, path: Path) -> None:
self._config = GitHubConfig.model_validate(
self.config = GitHubConfig.model_validate(
yaml.safe_load(path.read_text())
)


class CiManagerDependency:
"""A process-global object to manage background CI workers.
It is important to close this when Mobu shuts down to make sure that
GitHub PRs that use the mobu CI app functionality don't have stuck
check runs.
"""

def __init__(self) -> None:
self.ci_manager: CiManager

def __call__(self) -> CiManager:
return self.ci_manager

async def initialize(
self, base_context: ContextDependency, users: list[User]
) -> None:
self.ci_manager = CiManager(
users=users,
http_client=base_context.process_context.http_client,
gafaelfawr_storage=base_context.process_context.gafaelfawr,
logger=base_context.process_context.logger,
)

async def aclose(self) -> None:
await self.ci_manager.aclose()


class MaybeCiManagerDependency:
"""Try to return a CiManager, but don't blow up if it's not there.
Used in external routes that return info about mobu, and may be called on
installations that do not have the github ci functionality enabled.
"""

def __init__(self, dep: CiManagerDependency) -> None:
self.dep = dep

def __call__(self) -> CiManager | None:
try:
return self.dep.ci_manager
except AttributeError:
return None


github_config_dependency = GitHubConfigDependency()
ci_manager_dependency = CiManagerDependency()
maybe_ci_manager_dependency = MaybeCiManagerDependency(ci_manager_dependency)
8 changes: 5 additions & 3 deletions src/mobu/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ class ProcessContext:

def __init__(self, http_client: AsyncClient) -> None:
self.http_client = http_client
logger = structlog.get_logger("mobu")
gafaelfawr = GafaelfawrStorage(http_client, logger)
self.manager = FlockManager(gafaelfawr, http_client, logger)
self.logger = structlog.get_logger("mobu")
self.gafaelfawr = GafaelfawrStorage(self.http_client, self.logger)
self.manager = FlockManager(
self.gafaelfawr, self.http_client, self.logger
)

async def aclose(self) -> None:
"""Clean up a process context.
Expand Down
150 changes: 150 additions & 0 deletions src/mobu/handlers/github_ci_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Github webhook handlers for CI app."""

import asyncio
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from gidgethub import routing
from gidgethub.sansio import Event
from safir.github.webhooks import GitHubCheckRunEventModel
from safir.slack.webhook import SlackRouteErrorHandler

from ..config import config
from ..constants import GITHUB_WEBHOOK_WAIT_SECONDS
from ..dependencies.context import RequestContext, anonymous_context_dependency
from ..dependencies.github import (
ci_manager_dependency,
github_config_dependency,
)
from ..models.github import GitHubCheckSuiteEventModel, GitHubConfig
from ..services.github_ci.ci_manager import CiManager

__all__ = ["api_router"]

api_router = APIRouter(route_class=SlackRouteErrorHandler)
"""Registers incoming HTTP GitHub webhook requests"""


gidgethub_router = routing.Router()
"""Registers handlers for specific GitHub webhook payloads"""


@api_router.post(
"/webhook",
summary="GitHub CI webhooks",
description="Receives webhook events from the GitHub mobu CI app.",
status_code=202,
)
async def post_webhook(
context: Annotated[RequestContext, Depends(anonymous_context_dependency)],
github_config: Annotated[GitHubConfig, Depends(github_config_dependency)],
ci_manager: Annotated[CiManager, Depends(ci_manager_dependency)],
) -> None:
"""Process GitHub webhook events for the mobu CI GitHubApp.
Rejects webhooks from organizations that are not explicitly allowed via the
mobu config. This should be exposed via a Gafaelfawr anonymous ingress.
"""
webhook_secret = config.github_ci_app.webhook_secret
body = await context.request.body()
event = Event.from_http(
context.request.headers, body, secret=webhook_secret
)

owner = event.data.get("organization", {}).get("login")
if owner not in github_config.accepted_github_orgs:
context.logger.debug(
"Ignoring GitHub event for unaccepted org",
owner=owner,
accepted_orgs=github_config.accepted_github_orgs,
)
raise HTTPException(
status_code=403,
detail=(
"Mobu is not configured to accept webhooks from this GitHub"
" org."
),
)

# Bind the X-GitHub-Delivery header to the logger context; this
# identifies the webhook request in GitHub's API and UI for
# diagnostics
context.rebind_logger(github_app="ci", github_delivery=event.delivery_id)
context.logger.debug("Received GitHub webhook", payload=event.data)
# Give GitHub some time to reach internal consistency.
await asyncio.sleep(GITHUB_WEBHOOK_WAIT_SECONDS)
await gidgethub_router.dispatch(
event=event, context=context, ci_manager=ci_manager
)


@gidgethub_router.register("check_suite", action="requested")
async def handle_check_suite_requested(
event: Event, context: RequestContext, ci_manager: CiManager
) -> None:
"""Start a run for any check suite request with an associated PR."""
context.rebind_logger(
github_webhook_event_type="check_suite",
github_webhook_action="requested",
)
em = GitHubCheckSuiteEventModel.model_validate(event.data)
if not bool(em.check_suite.pull_requests):
context.logger.debug("Ignoring; no associated pull requests")
return

await ci_manager.enqueue(
installation_id=em.installation.id,
repo_name=em.repository.name,
repo_owner=em.repository.owner.login,
ref=em.check_suite.head_sha,
)

context.logger.info("github ci webhook handled")


@gidgethub_router.register("check_suite", action="rerequested")
async def handle_check_suite_rerequested(
event: Event, context: RequestContext, ci_manager: CiManager
) -> None:
"""Start a run for any check suite re-request with an associated PR."""
context.rebind_logger(
github_webhook_event_type="check_suite",
github_webhook_action="rerequested",
)
em = GitHubCheckSuiteEventModel.model_validate(event.data)
if not bool(em.check_suite.pull_requests):
context.logger.debug("Ignoring; no associated pull requests")
return

await ci_manager.enqueue(
installation_id=em.installation.id,
repo_name=em.repository.name,
repo_owner=em.repository.owner.login,
ref=em.check_suite.head_sha,
)

context.logger.info("github ci webhook handled")


@gidgethub_router.register("check_run", action="rerequested")
async def handle_check_run_rerequested(
event: Event, context: RequestContext, ci_manager: CiManager
) -> None:
"""Start a run for any check run re-request with an associated PR."""
context.rebind_logger(
github_webhook_event_type="check_run",
github_webhook_action="rerequested",
)
em = GitHubCheckRunEventModel.model_validate(event.data)
if not bool(em.check_run.pull_requests):
context.logger.debug("Ignoring; no associated pull requests")
return

await ci_manager.enqueue(
installation_id=em.installation.id,
repo_name=em.repository.name,
repo_owner=em.repository.owner.login,
ref=em.check_run.head_sha,
)

context.logger.info("github ci webhook handled")
5 changes: 0 additions & 5 deletions src/mobu/handlers/github_refresh_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,6 @@ async def post_webhook(
Rejects webhooks from organizations that are not explicitly allowed via the
mobu config. This should be exposed via a Gafaelfawr anonymous ingress.
"""
if not config.github_refresh_app.enabled:
raise HTTPException(
status_code=404,
detail="GitHub refresh app not enabled in this environment",
)
webhook_secret = config.github_refresh_app.webhook_secret
body = await context.request.body()
event = Event.from_http(
Expand Down
39 changes: 38 additions & 1 deletion src/mobu/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@

from .asyncio import schedule_periodic
from .config import config
from .dependencies.context import context_dependency
from .dependencies.context import ContextDependency, context_dependency
from .dependencies.github import (
ci_manager_dependency,
github_config_dependency,
)
from .handlers.external import external_router
from .handlers.github_ci_app import api_router as github_ci_app_router
from .handlers.github_refresh_app import (
api_router as github_refresh_app_router,
)
Expand All @@ -44,6 +46,7 @@ async def base_lifespan(app: FastAPI) -> AsyncIterator[ContextDependency]:
raise RuntimeError("MOBU_ENVIRONMENT_URL was not set")
if not config.gafaelfawr_token:
raise RuntimeError("MOBU_GAFAELFAWR_TOKEN was not set")

await context_dependency.initialize()
await context_dependency.process_context.manager.autostart()

Expand All @@ -56,6 +59,32 @@ async def base_lifespan(app: FastAPI) -> AsyncIterator[ContextDependency]:
app.state.periodic_status.cancel()


@asynccontextmanager
async def github_ci_app_lifespan(
base_context: ContextDependency,
) -> AsyncIterator[None]:
"""Set up and tear down the GitHub CI app functionality."""
if not config.github_config_path:
raise RuntimeError("MOBU_GITHUB_CONFIG_PATH was not set")
if not config.github_ci_app.webhook_secret:
raise RuntimeError("MOBU_GITHUB_CI_APP_WEBHOOK_SECRET was not set")
if not config.github_ci_app.private_key:
raise RuntimeError("MOBU_GITHUB_CI_APP_PRIVATE_KEY was not set")
if not config.github_ci_app.id:
raise RuntimeError("MOBU_GITHUB_CI_APP_ID was not set")

github_config_dependency.initialize(config.github_config_path)
await ci_manager_dependency.initialize(
base_context=base_context,
users=github_config_dependency.config.users,
)
await ci_manager_dependency.ci_manager.start()

yield

await ci_manager_dependency.aclose()


@asynccontextmanager
async def github_refresh_app_lifespan() -> AsyncIterator[None]:
"""Set up and tear down the GitHub refresh app functionality."""
Expand All @@ -79,6 +108,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""
async with AsyncExitStack() as stack:
base_context = await stack.enter_async_context(base_lifespan(app))
if config.github_ci_app.enabled:
await stack.enter_async_context(
github_ci_app_lifespan(base_context)
)
if config.github_refresh_app.enabled:
await stack.enter_async_context(github_refresh_app_lifespan())

Expand Down Expand Up @@ -106,6 +139,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.include_router(internal_router)
app.include_router(external_router, prefix=config.path_prefix)

if config.github_ci_app.enabled:
app.include_router(
github_ci_app_router, prefix=f"{config.path_prefix}/github/ci"
)
if config.github_refresh_app.enabled:
app.include_router(
github_refresh_app_router,
Expand Down
Loading

0 comments on commit 0e0936f

Please sign in to comment.