Skip to content

Commit

Permalink
feat: add support for a pin_workflow policy
Browse files Browse the repository at this point in the history
  • Loading branch information
netomi committed Oct 18, 2024
1 parent 3e68a6c commit 3b8dcba
Show file tree
Hide file tree
Showing 20 changed files with 1,318 additions and 465 deletions.
20 changes: 11 additions & 9 deletions DEPENDENCIES
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
pypi/pypi/-/aiofiles/24.1.0
pypi/pypi/-/aiohappyeyeballs/2.4.3
pypi/pypi/-/aiohttp/3.10.9
pypi/pypi/-/aiohttp/3.10.10
pypi/pypi/-/aiohttp-client-cache/0.12.3
pypi/pypi/-/aiohttp-client-cache/0.12.3
pypi/pypi/-/aiohttp-retry/2.8.3
pypi/pypi/-/aioshutil/1.5
pypi/pypi/-/aiosignal/1.3.1
pypi/pypi/-/aiosqlite/0.20.0
pypi/pypi/-/annotated-types/0.7.0
pypi/pypi/-/anyio/4.6.0
pypi/pypi/-/anyio/4.6.2.post1
pypi/pypi/-/ariadne/0.22
pypi/pypi/-/async-timeout/4.0.3
pypi/pypi/-/attrs/24.2.0
pypi/pypi/-/babel/2.16.0
pypi/pypi/-/blinker/1.8.2
pypi/pypi/-/certifi/2024.8.30
pypi/pypi/-/cffi/1.17.1
pypi/pypi/-/charset-normalizer/3.3.2
pypi/pypi/-/charset-normalizer/3.4.0
pypi/pypi/-/chevron/0.14.0
pypi/pypi/-/click/8.1.7
pypi/pypi/-/colorama/0.4.6
Expand All @@ -30,7 +30,7 @@ pypi/pypi/-/gitdb/4.0.11
pypi/pypi/-/github-flask/3.2.0
pypi/pypi/-/gitpython/3.1.43
pypi/pypi/-/gojsonnet/0.20.0
pypi/pypi/-/graphql-core/3.2.4
pypi/pypi/-/graphql-core/3.2.5
pypi/pypi/-/greenlet/3.0.3
pypi/pypi/-/h11/0.14.0
pypi/pypi/-/h2/4.1.0
Expand All @@ -44,16 +44,16 @@ pypi/pypi/-/jinja2/3.1.4
pypi/pypi/-/jsonata-python/0.5.0
pypi/pypi/-/jsonbender/0.9.3
pypi/pypi/-/jsonschema/4.23.0
pypi/pypi/-/jsonschema-specifications/2023.12.1
pypi/pypi/-/jsonschema-specifications/2024.10.1
pypi/pypi/-/jwt/1.3.1
pypi/pypi/-/markdown/3.7
pypi/pypi/-/markupsafe/2.1.5
pypi/pypi/-/markupsafe/3.0.1
pypi/pypi/-/mergedeep/1.3.4
pypi/pypi/-/mintotp/0.3.0
pypi/pypi/-/mkdocs/1.6.1
pypi/pypi/-/mkdocs-exclude/1.0.2
pypi/pypi/-/mkdocs-get-deps/0.2.0
pypi/pypi/-/mkdocs-material/9.5.39
pypi/pypi/-/mkdocs-material/9.5.41
pypi/pypi/-/mkdocs-material-extensions/1.3.1
pypi/pypi/-/motor/3.6.0
pypi/pypi/-/multidict/6.1.0
Expand All @@ -64,6 +64,7 @@ pypi/pypi/-/pathspec/0.12.1
pypi/pypi/-/platformdirs/4.3.6
pypi/pypi/-/playwright/1.47.0
pypi/pypi/-/priority/2.0.0
pypi/pypi/-/propcache/0.2.0
pypi/pypi/-/pycparser/2.22
pypi/pypi/-/pydantic/2.9.2
pypi/pypi/-/pydantic-core/2.23.4
Expand All @@ -86,10 +87,11 @@ pypi/pypi/-/referencing/0.35.1
pypi/pypi/-/regex/2024.9.11
pypi/pypi/-/requests/2.32.3
pypi/pypi/-/rpds-py/0.20.0
pypi/pypi/-/semver/3.0.2
pypi/pypi/-/six/1.16.0
pypi/pypi/-/smmap/5.0.1
pypi/pypi/-/sniffio/1.3.1
pypi/pypi/-/starlette/0.39.2
pypi/pypi/-/starlette/0.41.0
pypi/pypi/-/taskgroup/0.0.0a4
pypi/pypi/-/tomli/2.0.2
pypi/pypi/-/typing-extensions/4.12.2
Expand All @@ -98,4 +100,4 @@ pypi/pypi/-/urllib3/2.2.3
pypi/pypi/-/watchdog/5.0.3
pypi/pypi/-/werkzeug/3.0.4
pypi/pypi/-/wsproto/1.2.0
pypi/pypi/-/yarl/1.13.1
pypi/pypi/-/yarl/1.15.4
12 changes: 12 additions & 0 deletions otterdog/providers/github/rest/action_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from typing import Any

from otterdog.providers.github.exception import GitHubException
from otterdog.utils import print_debug

from . import RestApi, RestClient
Expand All @@ -15,6 +18,15 @@ class ActionClient(RestClient):
def __init__(self, rest_api: RestApi):
super().__init__(rest_api)

async def get_workflows(self, owner: str, repo: str) -> list[dict[str, Any]]:
print_debug(f"retrieving workflows for repo '{owner}/{repo}'")

try:
result = await self.requester.request_json("GET", f"/repos/{owner}/{repo}/actions/workflows")
return result["workflows"]
except GitHubException as ex:
raise RuntimeError(f"failed retrieving workflows for '{owner}/{repo}':\n{ex}") from ex

async def cancel_workflow_run(self, org_id: str, repo_name: str, run_id: str) -> bool:
print_debug(f"cancelling workflow run #{run_id} in repo '{org_id}/{repo_name}'")

Expand Down
36 changes: 28 additions & 8 deletions otterdog/providers/github/rest/reference_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,44 @@ class ReferenceClient(RestClient):
def __init__(self, rest_api: RestApi):
super().__init__(rest_api)

async def get_branch_reference(self, org_id: str, repo_name: str, branch_name: str) -> dict[str, Any]:
print_debug(f"getting branch reference with name '{branch_name}' from repo '{org_id}/{repo_name}'")
async def get_branch_reference(self, owner: str, repo: str, branch_name: str) -> dict[str, Any]:
print_debug(f"getting branch reference with name '{branch_name}' from repo '{owner}/{repo}'")

try:
return await self.requester.request_json("GET", f"/repos/{org_id}/{repo_name}/git/ref/heads/{branch_name}")
return await self.requester.request_json("GET", f"/repos/{owner}/{repo}/git/ref/heads/{branch_name}")
except GitHubException as ex:
raise RuntimeError(f"failed retrieving reference:\n{ex}") from ex
raise RuntimeError(f"failed retrieving reference for repo '{owner}/{repo}':\n{ex}") from ex

async def create_reference(self, org_id: str, repo_name: str, ref: str, sha: str) -> str:
print_debug(f"creating reference with name '{ref}' and sha '{sha}' for repo '{org_id}/{repo_name}'")
async def get_matching_references(self, owner: str, repo: str, ref_pattern: str) -> list[dict[str, Any]]:
print_debug(f"getting matching references with pattern '{ref_pattern}' from repo '{owner}/{repo}'")

try:
return await self.requester.request_json("GET", f"/repos/{owner}/{repo}/git/matching-refs/{ref_pattern}")
except GitHubException as ex:
raise RuntimeError(f"failed retrieving matching references for repo '{owner}/{repo}':\n{ex}") from ex

async def create_reference(self, owner: str, repo: str, ref: str, sha: str) -> str:
print_debug(f"creating reference with name '{ref}' and sha '{sha}' in repo '{owner}/{repo}'")

try:
data = {
"ref": f"refs/heads/{ref}",
"sha": sha,
}

await self.requester.request_json("POST", f"/repos/{org_id}/{repo_name}/git/refs", data=data)
await self.requester.request_json("POST", f"/repos/{owner}/{repo}/git/refs", data=data)
return data["ref"]
except GitHubException as ex:
raise RuntimeError(f"failed creating reference:\n{ex}") from ex
raise RuntimeError(f"failed creating reference in repo '{owner}/{repo}':\n{ex}") from ex

async def delete_reference(self, owner: str, repo: str, ref: str) -> bool:
print_debug(f"deleting reference with name '{ref}' in repo '{owner}/{repo}'")

full_ref = f"refs/heads/{ref}"
status, body = await self.requester.request_raw("DELETE", f"/repos/{owner}/{repo}/git/{full_ref}")
if status == 204:
return True
elif status in (409, 422):
return False
else:
raise RuntimeError(f"failed deleting reference '{ref}' in repo '{owner}/{repo}'" f"\n{status}: {body}")
8 changes: 8 additions & 0 deletions otterdog/providers/github/rest/repo_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,14 @@ async def get_branches(self, org_id: str, repo_name) -> list[dict[str, Any]]:
except GitHubException as ex:
raise RuntimeError(f"failed getting branches for repo '{org_id}/{repo_name}':\n{ex}") from ex

async def get_tags(self, owner: str, repo: str) -> list[dict[str, Any]] | None:
print_debug(f"retrieving tags for repo '{owner}/{repo}'")

try:
return await self.requester.request_paged_json("GET", f"/repos/{owner}/{repo}/tags")
except GitHubException as ex:
raise RuntimeError(f"failed getting tags for repo '{owner}/{repo}':\n{ex}") from ex

async def get_environments(self, org_id: str, repo_name: str) -> list[dict[str, Any]]:
print_debug(f"retrieving environments for repo '{org_id}/{repo_name}'")

Expand Down
4 changes: 2 additions & 2 deletions otterdog/webapp/db/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,10 +625,10 @@ async def find_policy(owner: str, policy_type) -> PolicyModel | None:


async def update_or_create_policy(owner: str, policy: Policy) -> None:
policy_model = await find_policy(owner, policy.type.value)
policy_model = await find_policy(owner, policy.policy_type().value)
if policy_model is None:
policy_model = PolicyModel(
id=PolicyId(org_id=owner, policy_type=policy.type.value),
id=PolicyId(org_id=owner, policy_type=policy.policy_type().value),
config=policy.config,
)
else:
Expand Down
4 changes: 2 additions & 2 deletions otterdog/webapp/internal/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
update_data_for_installation,
update_installations_from_config,
)
from otterdog.webapp.policies import create_policy
from otterdog.webapp.policies import Policy
from otterdog.webapp.utils import refresh_global_policies, refresh_otterdog_config

from . import blueprint
Expand Down Expand Up @@ -50,7 +50,7 @@ async def check():
logger.debug(f"checking org {org_id}")

for policy_model in await get_policies(org_id):
policy = create_policy(policy_model.id.policy_type, policy_model.config)
policy = Policy.create(policy_model.id.policy_type, policy_model.config)

await policy.evaluate(org_id)

Expand Down
49 changes: 31 additions & 18 deletions otterdog/webapp/policies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,31 @@

from __future__ import annotations

import re
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any
from functools import cached_property
from typing import TYPE_CHECKING, Any, TypeVar, final

from pydantic import BaseModel

if TYPE_CHECKING:
from otterdog.models.repository import Repository


class PolicyType(str, Enum):
MACOS_LARGE_RUNNERS_USAGE = "macos_large_runners"
REQUIRED_FILE = "required_file"
PIN_WORKFLOW = "pin_workflow"


PT = TypeVar("PT", bound="Policy")


class Policy(ABC, BaseModel):
@property
@classmethod
@abstractmethod
def type(self) -> PolicyType: ...
def policy_type(cls) -> PolicyType: ...

@property
def config(self) -> dict[str, Any]:
Expand All @@ -32,26 +41,30 @@ def config(self) -> dict[str, Any]:
@abstractmethod
async def evaluate(self, github_id: str) -> None: ...

@classmethod
@final
def create(cls: type[PT], policy_type: PolicyType | str, config: dict[str, Any]) -> PT:
from .macos_large_runners import MacOSLargeRunnersUsagePolicy
from .pin_workflow import PinWorkflowPolicy
from .required_file import RequiredFilePolicy

def read_policy(content: dict[str, Any]) -> Policy:
policy_type = content["type"]
return create_policy(policy_type, content["config"])
if isinstance(policy_type, str):
policy_type = PolicyType(policy_type)

return next(c for c in cls.__subclasses__() if c.policy_type() == policy_type).model_validate(config)

def create_policy(policy_type: PolicyType | str, config: dict[str, Any]) -> Policy:
if isinstance(policy_type, str):
policy_type = PolicyType(policy_type)

match policy_type:
case PolicyType.MACOS_LARGE_RUNNERS_USAGE:
from otterdog.webapp.policies.macos_large_runners import MacOSLargeRunnersUsagePolicy
def read_policy(content: dict[str, Any]) -> Policy:
policy_type = content["type"]
return Policy.create(policy_type, content["config"])

return MacOSLargeRunnersUsagePolicy.model_validate(config)

case PolicyType.REQUIRED_FILE:
from otterdog.webapp.policies.required_file import RequiredFilePolicy
class RepoSelector(BaseModel):
name_pattern: str | None

return RequiredFilePolicy.model_validate(config)
@cached_property
def _pattern(self):
return re.compile(self.name_pattern)

case _:
raise RuntimeError(f"unknown policy type '{policy_type}'")
def matches(self, repo: Repository) -> bool:
return self._pattern.match(repo.name)
6 changes: 4 additions & 2 deletions otterdog/webapp/policies/macos_large_runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from __future__ import annotations

from . import Policy, PolicyType


class MacOSLargeRunnersUsagePolicy(Policy):
allowed: bool

@property
def type(self) -> PolicyType:
@classmethod
def policy_type(cls) -> PolicyType:
return PolicyType.MACOS_LARGE_RUNNERS_USAGE

async def evaluate(self, github_id: str) -> None:
Expand Down
55 changes: 55 additions & 0 deletions otterdog/webapp/policies/pin_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# *******************************************************************************
# Copyright (c) 2024 Eclipse Foundation and others.
# This program and the accompanying materials are made available
# under the terms of the Eclipse Public License 2.0
# which is available at http://www.eclipse.org/legal/epl-v20.html
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from __future__ import annotations

from logging import getLogger

from quart import current_app

from otterdog.models.github_organization import GitHubOrganization
from otterdog.webapp.db.service import get_configuration_by_github_id, get_installation_by_github_id
from otterdog.webapp.policies import Policy, PolicyType, RepoSelector
from otterdog.webapp.tasks.pin_workflow import PinWorkflowTask

logger = getLogger(__name__)


class PinWorkflowPolicy(Policy):
repo_selector: RepoSelector

@classmethod
def policy_type(cls) -> PolicyType:
return PolicyType.PIN_WORKFLOW

async def evaluate(self, github_id: str) -> None:
installation = await get_installation_by_github_id(github_id)
if installation is None:
return

config_data = await get_configuration_by_github_id(github_id)
if config_data is None:
return

github_organization = GitHubOrganization.from_model_data(config_data.config)
for repo in github_organization.repositories:
if self.repo_selector.matches(repo):
logger.debug(f"checking for unpinned workflows in repo '{github_id}/{repo.name}'")

title = "Pinning workflows"
body = "This PR has been automatically created by otterdog due to an active policy."

current_app.add_background_task(
PinWorkflowTask(
installation.installation_id,
github_id,
repo.name,
title,
body,
)
)
Loading

0 comments on commit 3b8dcba

Please sign in to comment.