Skip to content

Commit

Permalink
feat: add check sync task
Browse files Browse the repository at this point in the history
  • Loading branch information
Thomas Neidhart committed Jan 30, 2024
1 parent 70a2ff7 commit 1a9d36e
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 48 deletions.
13 changes: 13 additions & 0 deletions otterdog/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ def of_changes(
LivePatchType.CHANGE, expected_object, current_object, changes, parent_object, forced_update, fn
)

def requires_secrets(self) -> bool:
match self.patch_type:
case LivePatchType.ADD:
assert self.expected_object is not None
return self.expected_object.contains_secrets()

case LivePatchType.REMOVE:
return False

case LivePatchType.CHANGE:
assert self.expected_object is not None
return self.expected_object.contains_secrets()

async def apply(self, org_id: str, provider: GitHubProvider) -> None:
await self.fn(self, org_id, provider)

Expand Down
2 changes: 1 addition & 1 deletion otterdog/operations/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ async def handle_finish(self, org_id: str, diff_status: DiffStatus, patches: lis

delete_snippet = "deleted" if self._delete_resources else "live resources ignored"

self.printer.println("\nDone.")
self.printer.println("Done.")

self.printer.println(
f"\n{style('Executed plan', bright=True)}: {diff_status.additions} added, "
Expand Down
43 changes: 29 additions & 14 deletions otterdog/operations/diff_operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************

from __future__ import annotations

import os
from abc import abstractmethod
from typing import Any, Optional
from typing import Any, Optional, Protocol

from otterdog.config import OrganizationConfig, OtterdogConfig
from otterdog.jsonnet import JsonnetConfig
Expand All @@ -34,6 +36,11 @@ def total_changes(self, include_deletions: bool) -> int:
return self.additions + self.differences


class CallbackFn(Protocol):
def __call__(self, org_id: str, diff_status: DiffStatus, patches: list[LivePatch]) -> None:
...


class DiffOperation(Operation):
def __init__(self, no_web_ui: bool, update_webhooks: bool, update_secrets: bool, update_filter: str):
super().__init__()
Expand All @@ -46,6 +53,7 @@ def __init__(self, no_web_ui: bool, update_webhooks: bool, update_secrets: bool,
self._validator = ValidateOperation()
self._template_dir: Optional[str] = None
self._org_config: Optional[OrganizationConfig] = None
self._callback: Optional[CallbackFn] = None

@property
def template_dir(self) -> str:
Expand All @@ -57,6 +65,9 @@ def org_config(self) -> OrganizationConfig:
assert self._org_config is not None
return self._org_config

def set_callback(self, fn: CallbackFn) -> None:
self._callback = fn

def init(self, config: OtterdogConfig, printer: IndentingPrinter) -> None:
super().init(config, printer)
self._validator.init(config, printer)
Expand Down Expand Up @@ -144,14 +155,16 @@ async def _generate_diff(self, org_config: OrganizationConfig) -> int:
live_patches = []

def handle(patch: LivePatch) -> None:
if not self.include_resources_with_secrets() and patch.requires_secrets():
return

live_patches.append(patch)

match patch.patch_type:
case LivePatchType.ADD:
assert patch.expected_object is not None
if self.include_resources_with_secrets() or not patch.expected_object.contains_secrets():
self.handle_add_object(github_id, patch.expected_object, patch.parent_object)
diff_status.additions += 1
self.handle_add_object(github_id, patch.expected_object, patch.parent_object)
diff_status.additions += 1

case LivePatchType.REMOVE:
assert patch.current_object is not None
Expand All @@ -162,16 +175,14 @@ def handle(patch: LivePatch) -> None:
assert patch.changes is not None
assert patch.current_object is not None
assert patch.expected_object is not None

if self.include_resources_with_secrets() or not patch.expected_object.contains_secrets():
diff_status.differences += self.handle_modified_object(
github_id,
patch.changes,
False,
patch.current_object,
patch.expected_object,
patch.parent_object,
)
diff_status.differences += self.handle_modified_object(
github_id,
patch.changes,
False,
patch.current_object,
patch.expected_object,
patch.parent_object,
)

context = LivePatchContext(
github_id, self.update_webhooks, self.update_secrets, self.update_filter, expected_org.settings
Expand Down Expand Up @@ -199,6 +210,10 @@ def handle(patch: LivePatch) -> None:
live_patch.expected_object.resolve_secrets(self.config.get_secret)

await self.handle_finish(github_id, diff_status, live_patches)

if self._callback is not None:
self._callback(github_id, diff_status, live_patches)

return 0

def load_expected_org(self, github_id: str, org_file_name: str) -> GitHubOrganization:
Expand Down
4 changes: 3 additions & 1 deletion otterdog/webapp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ class AppConfig(object):
# Set up the App SECRET_KEY
SECRET_KEY = config("SECRET_KEY")

GITHUB_ADMIN_TEAM = config("GITHUB_ADMIN_TEAM", default="otterdog-admins")
GITHUB_WEBHOOK_ENDPOINT = config("GITHUB_WEBHOOK_ENDPOINT", default="/github-webhook/receive")
GITHUB_WEBHOOK_SECRET = config("GITHUB_WEBHOOK_SECRET", default=None)
GITHUB_WEBHOOK_VALIDATION_CONTEXT = config("GITHUB_WEBHOOK_VALIDATION_CONTEXT", default="otterdog-check")
GITHUB_WEBHOOK_VALIDATION_CONTEXT = config("GITHUB_WEBHOOK_VALIDATION_CONTEXT", default="otterdog-validate")
GITHUB_WEBHOOK_SYNC_CONTEXT = config("GITHUB_WEBHOOK_SYNC_CONTEXT", default="otterdog-sync")

# GitHub App config
GITHUB_APP_ID = config("GITHUB_APP_ID")
Expand Down
4 changes: 3 additions & 1 deletion otterdog/webapp/tasks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,19 @@ def logger(self) -> Logger:
async def get_rest_api(installation_id: int) -> RestApi:
return await get_rest_api_for_installation(installation_id)

async def execute(self) -> None:
async def execute(self) -> Optional[T]:
self.logger.debug(f"executing task '{self!r}'")

await self._pre_execute()

try:
result = await self._execute()
await self._post_execute(result)
return result
except RuntimeError as ex:
self.logger.exception(f"failed to execute task '{self!r}'", exc_info=ex)
await self._post_execute(ex)
return None

async def _pre_execute(self) -> None:
pass
Expand Down
188 changes: 188 additions & 0 deletions otterdog/webapp/tasks/check_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# *******************************************************************************
# Copyright (c) 2023-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

import dataclasses
import os
from io import StringIO
from tempfile import TemporaryDirectory
from typing import Union, cast

from pydantic import ValidationError
from quart import current_app, render_template

from otterdog.config import OrganizationConfig
from otterdog.models import LivePatch
from otterdog.operations.diff_operation import DiffStatus
from otterdog.operations.plan import PlanOperation
from otterdog.providers.github import RestApi
from otterdog.utils import IndentingPrinter, LogLevel
from otterdog.webapp.tasks import Task, get_otterdog_config
from otterdog.webapp.tasks.validate_pull_request import (
escape_for_github,
get_admin_team,
get_config,
)
from otterdog.webapp.webhook.github_models import PullRequest, Repository


@dataclasses.dataclass(repr=False)
class CheckConfigurationInSyncTask(Task[bool]):
"""Checks whether the base ref is in sync with live settings."""

installation_id: int
org_id: str
repository: Repository
pull_request_or_number: PullRequest | int

async def _pre_execute(self) -> None:
rest_api = await self.get_rest_api(self.installation_id)

if isinstance(self.pull_request_or_number, int):
response = await rest_api.pull_request.get_pull_request(
self.org_id, self.repository.name, str(self.pull_request_or_number)
)
try:
self.pull_request = PullRequest.model_validate(response)
except ValidationError as ex:
self.logger.exception("failed to load pull request event data", exc_info=ex)
return
else:
self.pull_request = cast(PullRequest, self.pull_request_or_number)

self.logger.info(
"checking if base ref is in sync for pull request #%d of repo '%s'",
self.pull_request.number,
self.repository.full_name,
)

await self._create_pending_status(rest_api)

async def _post_execute(self, result_or_exception: Union[bool, Exception]) -> None:
rest_api = await self.get_rest_api(self.installation_id)

if isinstance(result_or_exception, Exception):
await self._create_failure_status(rest_api)
else:
await self._update_final_status(rest_api, result_or_exception)

async def _execute(self) -> bool:
otterdog_config = get_otterdog_config()
pull_request_number = str(self.pull_request.number)
project_name = otterdog_config.get_project_name(self.org_id) or self.org_id

rest_api = await self.get_rest_api(self.installation_id)

with TemporaryDirectory(dir=otterdog_config.jsonnet_base_dir) as work_dir:
org_config = OrganizationConfig.of(
project_name,
self.org_id,
{"provider": "inmemory", "api_token": rest_api.token},
work_dir,
otterdog_config,
)

jsonnet_config = org_config.jsonnet_config
if not os.path.exists(jsonnet_config.org_dir):
os.makedirs(jsonnet_config.org_dir)

jsonnet_config.init_template()

# get BASE config
base_file = jsonnet_config.org_config_file
await get_config(
rest_api,
self.org_id,
self.org_id,
otterdog_config.default_config_repo,
base_file,
self.pull_request.base.ref,
)

output = StringIO()
printer = IndentingPrinter(output, log_level=LogLevel.ERROR)
operation = PlanOperation(True, False, False, "")

config_in_sync = True

def sync_callback(org_id: str, diff_status: DiffStatus, patches: list[LivePatch]):
nonlocal config_in_sync
config_in_sync = diff_status.total_changes(True) == 0

operation.set_callback(sync_callback)
operation.init(otterdog_config, printer)

await operation.execute(org_config)

sync_output = output.getvalue()
self.logger.info("sync plan: " + sync_output)

if config_in_sync is False:
comment = await render_template(
"out_of_sync_comment.txt",
result=escape_for_github(sync_output),
admin_team=f"{self.org_id}/{get_admin_team()}",
)
else:
comment = await render_template("in_sync_comment.txt")

await rest_api.issue.create_comment(
self.org_id, otterdog_config.default_config_repo, pull_request_number, comment
)

return config_in_sync

async def _create_pending_status(self, rest_api: RestApi):
await rest_api.commit.create_commit_status(
self.org_id,
self.repository.name,
self.pull_request.head.sha,
"pending",
_get_webhook_sync_context(),
"checking if configuration is in-sync using otterdog",
)

async def _create_failure_status(self, rest_api: RestApi):
await rest_api.commit.create_commit_status(
self.org_id,
self.repository.name,
self.pull_request.head.sha,
"failure",
_get_webhook_sync_context(),
"otterdog sync check failed, please contact an admin",
)

async def _update_final_status(self, rest_api: RestApi, config_in_sync: bool) -> None:
if config_in_sync is True:
desc = "otterdog sync check completed successfully"
status = "success"
else:
desc = "otterdog sync check failed, check comment history"
status = "error"

await rest_api.commit.create_commit_status(
self.org_id,
self.repository.name,
self.pull_request.head.sha,
status,
_get_webhook_sync_context(),
desc,
)

def __repr__(self) -> str:
pull_request_number = (
self.pull_request_or_number
if isinstance(self.pull_request_or_number, int)
else self.pull_request_or_number.number
)
return f"CheckConfigurationInSyncTask(repo={self.repository.full_name}, pull_request={pull_request_number})"


def _get_webhook_sync_context() -> str:
return current_app.config["GITHUB_WEBHOOK_SYNC_CONTEXT"]
Loading

0 comments on commit 1a9d36e

Please sign in to comment.