diff --git a/otterdog/models/__init__.py b/otterdog/models/__init__.py index 91ad7f9e..1aae4ac3 100644 --- a/otterdog/models/__init__.py +++ b/otterdog/models/__init__.py @@ -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) diff --git a/otterdog/operations/apply.py b/otterdog/operations/apply.py index 302e3f68..866b0ad5 100644 --- a/otterdog/operations/apply.py +++ b/otterdog/operations/apply.py @@ -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, " diff --git a/otterdog/operations/diff_operation.py b/otterdog/operations/diff_operation.py index 8a81c76e..6e9921ec 100644 --- a/otterdog/operations/diff_operation.py +++ b/otterdog/operations/diff_operation.py @@ -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 @@ -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__() @@ -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: @@ -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) @@ -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 @@ -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 @@ -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: diff --git a/otterdog/webapp/config.py b/otterdog/webapp/config.py index 2c6d1b37..cff0bfc9 100644 --- a/otterdog/webapp/config.py +++ b/otterdog/webapp/config.py @@ -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") diff --git a/otterdog/webapp/tasks/__init__.py b/otterdog/webapp/tasks/__init__.py index e956375c..259c7d04 100644 --- a/otterdog/webapp/tasks/__init__.py +++ b/otterdog/webapp/tasks/__init__.py @@ -38,7 +38,7 @@ 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() @@ -46,9 +46,11 @@ async def execute(self) -> None: 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 diff --git a/otterdog/webapp/tasks/check_sync.py b/otterdog/webapp/tasks/check_sync.py new file mode 100644 index 00000000..3886ab4d --- /dev/null +++ b/otterdog/webapp/tasks/check_sync.py @@ -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"] diff --git a/otterdog/webapp/tasks/validate_pull_request.py b/otterdog/webapp/tasks/validate_pull_request.py index 131038d1..5a7d04e8 100644 --- a/otterdog/webapp/tasks/validate_pull_request.py +++ b/otterdog/webapp/tasks/validate_pull_request.py @@ -6,6 +6,8 @@ # SPDX-License-Identifier: EPL-2.0 # ******************************************************************************* +from __future__ import annotations + import dataclasses import filecmp import os @@ -18,6 +20,8 @@ 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.local_plan import LocalPlanOperation from otterdog.providers.github import RestApi from otterdog.utils import IndentingPrinter, LogLevel @@ -25,8 +29,15 @@ from otterdog.webapp.webhook.github_models import PullRequest, Repository +@dataclasses.dataclass +class ValidationResult: + plan_output: str = "" + validation_success: bool = True + requires_secrets: bool = False + + @dataclasses.dataclass(repr=False) -class ValidatePullRequestTask(Task[int]): +class ValidatePullRequestTask(Task[ValidationResult]): """Validates a PR and adds the result as a comment.""" installation_id: int @@ -35,6 +46,10 @@ class ValidatePullRequestTask(Task[int]): pull_request_or_number: PullRequest | int log_level: LogLevel = LogLevel.WARN + @property + def check_base_config(self) -> bool: + return True + async def _pre_execute(self) -> None: rest_api = await self.get_rest_api(self.installation_id) @@ -59,7 +74,18 @@ async def _pre_execute(self) -> None: await self._create_pending_status(rest_api) - async def _post_execute(self, result_or_exception: Union[int, Exception]) -> None: + from .check_sync import CheckConfigurationInSyncTask + + check_task = CheckConfigurationInSyncTask( + self.installation_id, + self.org_id, + self.repository, + self.pull_request_or_number, + ) + + current_app.add_background_task(check_task.execute) + + async def _post_execute(self, result_or_exception: Union[ValidationResult, Exception]) -> None: rest_api = await self.get_rest_api(self.installation_id) if isinstance(result_or_exception, Exception): @@ -67,7 +93,7 @@ async def _post_execute(self, result_or_exception: Union[int, Exception]) -> Non else: await self._update_final_status(rest_api, result_or_exception) - async def _execute(self) -> int: + async def _execute(self) -> ValidationResult: 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 @@ -111,29 +137,47 @@ async def _execute(self) -> int: self.pull_request.head.ref, ) - if filecmp.cmp(base_file, head_file): - self.logger.info("head and base config are identical, no need to validate") - return 0 + validation_result = ValidationResult() - output = StringIO() - printer = IndentingPrinter(output, log_level=self.log_level) - operation = LocalPlanOperation("-BASE", False, False, "") - operation.init(otterdog_config, printer) - - plan_result = await operation.execute(org_config) - - text = output.getvalue() - self.logger.info(text) - - result = await render_template( - "validation_comment.txt", sha=self.pull_request.head.sha, result=escape_for_github(text) + if filecmp.cmp(base_file, head_file): + self.logger.debug("head and base config are identical, no need to validate") + validation_result.plan_output = "No changes." + validation_result.validation_success = True + else: + output = StringIO() + printer = IndentingPrinter(output, log_level=self.log_level) + operation = LocalPlanOperation("-BASE", False, False, "") + + def callback(org_id: str, diff_status: DiffStatus, patches: list[LivePatch]): + validation_result.requires_secrets = any(list(map(lambda x: x.requires_secrets(), patches))) + + operation.set_callback(callback) + operation.init(otterdog_config, printer) + + plan_result = await operation.execute(org_config) + + validation_result.plan_output = output.getvalue() + validation_result.validation_success = plan_result == 0 + self.logger.info("local plan:" + validation_result.plan_output) + + warnings = [] + if validation_result.requires_secrets: + warnings.append("some of requested changes require secrets, need to apply these changes manually") + + comment = await render_template( + "validation_comment.txt", + sha=self.pull_request.head.sha, + result=escape_for_github(validation_result.plan_output), + warnings=warnings, + admin_team=f"{self.org_id}/{get_admin_team()}", ) + # add a comment about the validation result to the PR await rest_api.issue.create_comment( - self.org_id, otterdog_config.default_config_repo, pull_request_number, result + self.org_id, otterdog_config.default_config_repo, pull_request_number, comment ) - return plan_result + return validation_result async def _create_pending_status(self, rest_api: RestApi): await rest_api.commit.create_commit_status( @@ -141,7 +185,7 @@ async def _create_pending_status(self, rest_api: RestApi): self.repository.name, self.pull_request.head.sha, "pending", - _get_webhook_context(), + _get_webhook_validation_context(), "validating configuration change using otterdog", ) @@ -151,23 +195,24 @@ async def _create_failure_status(self, rest_api: RestApi): self.repository.name, self.pull_request.head.sha, "failure", - _get_webhook_context(), + _get_webhook_validation_context(), "otterdog validation failed, please contact an admin", ) - async def _update_final_status(self, rest_api: RestApi, execution_result: int) -> None: - desc = ( - "otterdog validation completed successfully" - if execution_result == 0 - else "otterdog validation failed, check validation result in comment history" - ) + async def _update_final_status(self, rest_api: RestApi, validation_result: ValidationResult) -> None: + if validation_result.validation_success is True: + desc = "otterdog validation completed successfully" + status = "success" + else: + desc = "otterdog validation failed, check validation result in comment history" + status = "error" await rest_api.commit.create_commit_status( self.org_id, self.repository.name, self.pull_request.head.sha, - "success" if execution_result == 0 else "error", - _get_webhook_context(), + status, + _get_webhook_validation_context(), desc, ) @@ -180,10 +225,14 @@ def __repr__(self) -> str: return f"ValidatePullRequestTask(repo={self.repository.full_name}, pull_request={pull_request_number})" -def _get_webhook_context() -> str: +def _get_webhook_validation_context() -> str: return current_app.config["GITHUB_WEBHOOK_VALIDATION_CONTEXT"] +def get_admin_team() -> str: + return current_app.config["GITHUB_ADMIN_TEAM"] + + async def get_config(rest_api: RestApi, org_id: str, owner: str, repo: str, filename: str, ref: str): path = f"otterdog/{org_id}.jsonnet" content = await rest_api.content.get_content( diff --git a/otterdog/webapp/templates/help_comment.txt b/otterdog/webapp/templates/help_comment.txt index 77ac3c1e..79f24ee0 100644 --- a/otterdog/webapp/templates/help_comment.txt +++ b/otterdog/webapp/templates/help_comment.txt @@ -3,3 +3,4 @@ This is your friendly self-service bot. The following commands are supported: - `/team-info`: checks the team / org membership for the PR author - `/validate`: validates the configuration change if this PR touches the configuration - `/validate info`: validates the configuration change, printing also validation infos +- `/check-sync`: checks if the base ref is in sync with live settings diff --git a/otterdog/webapp/templates/in_sync_comment.txt b/otterdog/webapp/templates/in_sync_comment.txt new file mode 100644 index 00000000..07c1b20f --- /dev/null +++ b/otterdog/webapp/templates/in_sync_comment.txt @@ -0,0 +1 @@ +This is your friendly self-service bot. The current configuration is in-sync with the live settings. :rocket: diff --git a/otterdog/webapp/templates/out_of_sync_comment.txt b/otterdog/webapp/templates/out_of_sync_comment.txt new file mode 100644 index 00000000..7c4f9315 --- /dev/null +++ b/otterdog/webapp/templates/out_of_sync_comment.txt @@ -0,0 +1,12 @@ +This is your friendly self-service bot. The current configuration is out-of-sync with the live settings: + +
+Diff to live settings + +```diff +{{ result }} +``` + +
+ +The current configuration needs to be updated to reflect the live settings otherwise they would be overwritten when this PR gets merged. cc @{{ admin_team }} diff --git a/otterdog/webapp/templates/validation_comment.txt b/otterdog/webapp/templates/validation_comment.txt index 6895a309..6e56d6e2 100644 --- a/otterdog/webapp/templates/validation_comment.txt +++ b/otterdog/webapp/templates/validation_comment.txt @@ -10,4 +10,15 @@ Please find below the validation of the requested configuration changes: +{% if (warnings is defined) and warnings %} +### Warnings + +{% for warning in warnings %} +- {{ warning }} +{% endfor %} + +cc @{{ admin_team }} + +{% endif %} + Add a comment `/help` to get a list of available commands. diff --git a/otterdog/webapp/webhook/__init__.py b/otterdog/webapp/webhook/__init__.py index f93f9748..fed9ec90 100644 --- a/otterdog/webapp/webhook/__init__.py +++ b/otterdog/webapp/webhook/__init__.py @@ -15,6 +15,7 @@ from otterdog.utils import LogLevel from otterdog.webapp.tasks import get_otterdog_config from otterdog.webapp.tasks.apply_changes import ApplyChangesTask +from otterdog.webapp.tasks.check_sync import CheckConfigurationInSyncTask from otterdog.webapp.tasks.help_comment import HelpCommentTask from otterdog.webapp.tasks.retrieve_team_membership import RetrieveTeamMembershipTask from otterdog.webapp.tasks.validate_pull_request import ValidatePullRequestTask @@ -110,6 +111,16 @@ async def on_issue_comment_received(data): ).execute ) return success() + elif re.match(r"\s*/check-sync\s*", event.comment.body) is not None: + current_app.add_background_task( + CheckConfigurationInSyncTask( + installation_id, + org_id, + event.repository, + event.issue.number, + ).execute + ) + return success() m = re.match(r"\s*/validate(\s+info)?\s*", event.comment.body) if m is None: