diff --git a/.gitignore b/.gitignore index c7ecbd9..51b64ae 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,7 @@ ENV/ # docker/helm docker/certificates/**/* -rules.py \ No newline at end of file +rules.py + +# config file +artifactory-cleanup.yaml diff --git a/README.md b/README.md index 01928d6..3da03c2 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Everything must be as a code, even cleanup policies! - [Filters](#filters) - [Create your own rule](#create-your-own-rule) - [How to](#how-to) + - [How to send cleanup report to Slack and Telegram?](#how-to-send-cleanup-report-to-slack-and-telegram) - [How to connect self-signed certificates for docker?](#how-to-connect-self-signed-certificates-for-docker) - [How to clean up Conan repository?](#how-to-clean-up-conan-repository) - [How to keep latest N docker images?](#how-to-keep-latest-n-docker-images) @@ -491,6 +492,58 @@ artifactory-cleanup --load-rules=myrule.py # How to +## How to send cleanup report to Slack and Telegram? + +`artifactory-cleanup` can upload the generated report file to Slack, Telegram, or both at once. +Report upload requires `--output`. + +### Option 1 - CLI flags + +#### Slack + Telegram + +```bash +artifactory-cleanup \ + --config artifactory-cleanup.yaml \ + --output result.json \ + --output-format json \ + --output-artifacts \ + --slack-token "$ARTIFACTORY_CLEANUP_SLACK_TOKEN" \ + --slack-channel-id C0123456789 \ + --telegram-token "$ARTIFACTORY_CLEANUP_TELEGRAM_TOKEN" \ + --telegram-chat-id -1001234567890 \ + --notify-comment "Artifactory cleanup report" +``` + +### Option 2 - YAML config + +```yaml +# artifactory-cleanup.yaml +artifactory-cleanup: + server: https://repo.example.com/artifactory + user: $ARTIFACTORY_USERNAME + password: $ARTIFACTORY_PASSWORD + + slack_token: $ARTIFACTORY_CLEANUP_SLACK_TOKEN + slack_channel_id: C0123456789 + telegram_token: $ARTIFACTORY_CLEANUP_TELEGRAM_TOKEN + telegram_chat_id: "-1001234567890" + notify_comment: "Artifactory cleanup report" + + policies: + - name: cleanup policy + rules: + - rule: Repo + name: "docker-local" + - rule: DeleteOlderThan + days: 30 +``` + +Then run: + +```bash +artifactory-cleanup --config artifactory-cleanup.yaml --output result.json --output-format json --output-artifacts +``` + ## How to connect self-signed certificates for docker? In case you have set up your Artifactory self-signed certificates, place all certificates of the chain of trust into diff --git a/artifactory_cleanup/__init__.py b/artifactory_cleanup/__init__.py index d5ee15e..9e317a2 100644 --- a/artifactory_cleanup/__init__.py +++ b/artifactory_cleanup/__init__.py @@ -7,4 +7,4 @@ def register(rule): registry.register(rule) -__version__ = "1.0.18" +__version__ = "1.1.0" diff --git a/artifactory_cleanup/cli.py b/artifactory_cleanup/cli.py index 98edb54..f44e7c8 100644 --- a/artifactory_cleanup/cli.py +++ b/artifactory_cleanup/cli.py @@ -15,11 +15,12 @@ ) from artifactory_cleanup.base_url_session import BaseUrlSession from artifactory_cleanup.context_managers import get_context_managers -from artifactory_cleanup.errors import InvalidConfigError +from artifactory_cleanup.errors import InvalidConfigError, NotificationError from artifactory_cleanup.loaders import ( PythonLoader, YamlConfigLoader, ) +from artifactory_cleanup.notifiers import ReportNotifier, SlackConfig, TelegramConfig requests.packages.urllib3.disable_warnings() @@ -102,6 +103,44 @@ class ArtifactoryCleanupCLI(cli.Application): default=False, ) + _slack_token = cli.SwitchAttr( + "--slack-token", + help="Slack bot token for report upload", + mandatory=False, + envname="ARTIFACTORY_CLEANUP_SLACK_TOKEN", + requires=["--slack-channel-id", "--output"], + ) + + _slack_channel_id = cli.SwitchAttr( + "--slack-channel-id", + help="Slack channel id for report upload", + mandatory=False, + envname="ARTIFACTORY_CLEANUP_SLACK_CHANNEL_ID", + requires=["--slack-token", "--output"], + ) + + _telegram_token = cli.SwitchAttr( + "--telegram-token", + help="Telegram bot token for report upload", + mandatory=False, + envname="ARTIFACTORY_CLEANUP_TELEGRAM_TOKEN", + requires=["--telegram-chat-id", "--output"], + ) + + _telegram_chat_id = cli.SwitchAttr( + "--telegram-chat-id", + help="Telegram chat id for report upload", + mandatory=False, + envname="ARTIFACTORY_CLEANUP_TELEGRAM_CHAT_ID", + requires=["--telegram-token", "--output"], + ) + + _notify_comment = cli.SwitchAttr( + "--notify-comment", + help="Comment attached to uploaded report", + mandatory=False, + ) + @property def VERSION(self): # To prevent circular imports @@ -153,6 +192,36 @@ def _create_output_file(self, result, filename, format): with open(filename, "w", encoding="utf-8") as file: file.write(text) + def _has_notifiers(self) -> bool: + return any( + [ + self._slack_token, + self._slack_channel_id, + self._telegram_token, + self._telegram_chat_id, + ] + ) + + def _build_notifier(self) -> ReportNotifier: + slack = None + if self._slack_token and self._slack_channel_id: + slack = SlackConfig( + token=self._slack_token, channel_id=self._slack_channel_id + ) + telegram = None + if self._telegram_token and self._telegram_chat_id: + telegram = TelegramConfig( + token=self._telegram_token, chat_id=self._telegram_chat_id + ) + return ReportNotifier(slack=slack, telegram=telegram) + + def _apply_notifier_config(self, config): + self._slack_token = self._slack_token or config.get("slack_token", "") + self._slack_channel_id = self._slack_channel_id or config.get("slack_channel_id", "") + self._telegram_token = self._telegram_token or config.get("telegram_token", "") + self._telegram_chat_id = self._telegram_chat_id or config.get("telegram_chat_id", "") + self._notify_comment = self._notify_comment or config.get("notify_comment", "") + def main(self): today = self._get_today() if self._load_rules: @@ -166,6 +235,8 @@ def main(self): print(str(err), file=sys.stderr) sys.exit(1) + self._apply_notifier_config(loader.get_notifications()) + server, user, password, apikey = loader.get_connection() session = BaseUrlSession(server) if apikey: @@ -218,6 +289,13 @@ def main(self): if self._output_file: self._create_output_file(result, self._output_file, self._output_format) + if self._has_notifiers(): + try: + notifier = self._build_notifier() + notifier.send_file(self._output_file, comment=self._notify_comment) + except NotificationError as err: + print(str(err), file=sys.stderr) + sys.exit(1) if __name__ == "__main__": diff --git a/artifactory_cleanup/errors.py b/artifactory_cleanup/errors.py index 01c4619..4cdea1e 100644 --- a/artifactory_cleanup/errors.py +++ b/artifactory_cleanup/errors.py @@ -4,3 +4,7 @@ class ArtifactoryCleanupException(Exception): class InvalidConfigError(ArtifactoryCleanupException): pass + + +class NotificationError(ArtifactoryCleanupException): + pass diff --git a/artifactory_cleanup/loaders.py b/artifactory_cleanup/loaders.py index 63aff57..782c8b8 100644 --- a/artifactory_cleanup/loaders.py +++ b/artifactory_cleanup/loaders.py @@ -94,11 +94,29 @@ def get_root_schema(self, rules): config_schema = cfgv.Map( "Config", None, - cfgv.NoAdditionalKeys(["server", "user", "password", "policies", "apikey"]), + cfgv.NoAdditionalKeys( + [ + "server", + "user", + "password", + "policies", + "apikey", + "slack_token", + "slack_channel_id", + "telegram_token", + "telegram_chat_id", + "notify_comment", + ] + ), cfgv.Required("server", cfgv.check_string), # User and password required, if apikey missing cfgv.Conditional("user", cfgv.check_string, "apikey", cfgv.MISSING, False), cfgv.Conditional("password", cfgv.check_string, "apikey", cfgv.MISSING, False), + cfgv.Optional("slack_token", cfgv.check_string, ""), + cfgv.Optional("slack_channel_id", cfgv.check_string, ""), + cfgv.Optional("telegram_token", cfgv.check_string, ""), + cfgv.Optional("telegram_chat_id", cfgv.check_string, ""), + cfgv.Optional("notify_comment", cfgv.check_string, ""), cfgv.RequiredRecurse("policies", cfgv.Array(policy_schema)), ) @@ -198,6 +216,21 @@ def get_connection(self) -> Tuple[str, str, str, str]: apikey = os.path.expandvars(apikey) return server, user, password, apikey + def get_notifications(self) -> Dict[str, str]: + config = self.load(self.filepath) + notifications = config.get("artifactory-cleanup", {}) + return { + "slack_token": os.path.expandvars(notifications.get("slack_token", "")), + "slack_channel_id": os.path.expandvars( + notifications.get("slack_channel_id", "") + ), + "telegram_token": os.path.expandvars(notifications.get("telegram_token", "")), + "telegram_chat_id": os.path.expandvars( + notifications.get("telegram_chat_id", "") + ), + "notify_comment": os.path.expandvars(notifications.get("notify_comment", "")), + } + class PythonLoader: """ diff --git a/artifactory_cleanup/notifiers.py b/artifactory_cleanup/notifiers.py new file mode 100644 index 0000000..653adbf --- /dev/null +++ b/artifactory_cleanup/notifiers.py @@ -0,0 +1,136 @@ +from abc import ABC, abstractmethod +import os +from dataclasses import dataclass +from typing import Optional, List + +import requests + +from artifactory_cleanup.errors import NotificationError + + +@dataclass +class SlackConfig: + token: str + channel_id: str + + +@dataclass +class TelegramConfig: + token: str + chat_id: str + + +class Notifier(ABC): + @abstractmethod + def send_file(self, filename: str, comment: Optional[str] = None): + pass + + +class SlackNotifier(Notifier): + def __init__(self, config: SlackConfig): + self._config = config + + def _headers(self): + return {"Authorization": f"Bearer {self._config.token}"} + + def send_file(self, filename: str, comment: Optional[str] = None): + if not os.path.exists(filename): + raise NotificationError(f"Report file not found: {filename}") + + file_size = os.path.getsize(filename) + file_basename = os.path.basename(filename) + metadata_response = requests.post( + "https://slack.com/api/files.getUploadURLExternal", + headers=self._headers(), + data={ + "filename": file_basename, + "length": file_size, + }, + timeout=30, + ) + metadata_response.raise_for_status() + metadata = metadata_response.json() + if not metadata.get("ok"): + raise NotificationError( + f"Slack upload URL request failed: {metadata.get('error', 'unknown error')}" + ) + + upload_url = metadata["upload_url"] + file_id = metadata["file_id"] + + with open(filename, "rb") as report_file: + upload_response = requests.post( + upload_url, + data=report_file, + headers={"Content-Type": "application/octet-stream"}, + timeout=60, + ) + upload_response.raise_for_status() + + complete_payload = { + "files": [{"id": file_id, "title": file_basename}], + "channel_id": self._config.channel_id, + } + if comment: + complete_payload["initial_comment"] = comment + + complete_response = requests.post( + "https://slack.com/api/files.completeUploadExternal", + headers={ + **self._headers(), + "Content-Type": "application/json; charset=utf-8", + }, + json=complete_payload, + timeout=30, + ) + complete_response.raise_for_status() + complete = complete_response.json() + if not complete.get("ok"): + raise NotificationError( + f"Slack upload finalize failed: {complete.get('error', 'unknown error')}" + ) + + +class TelegramNotifier(Notifier): + def __init__(self, config: TelegramConfig): + self._config = config + + def send_file(self, filename: str, comment: Optional[str] = None): + if not os.path.exists(filename): + raise NotificationError(f"Report file not found: {filename}") + + url = f"https://api.telegram.org/bot{self._config.token}/sendDocument" + data = {"chat_id": self._config.chat_id} + if comment: + data["caption"] = comment + + with open(filename, "rb") as report_file: + response = requests.post( + url, + data=data, + files={"document": report_file}, + timeout=60, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("ok"): + raise NotificationError( + f"Telegram send failed: {payload.get('description', 'unknown error')}" + ) + + +class ReportNotifier: + def __init__( + self, + slack: Optional[SlackConfig] = None, + telegram: Optional[TelegramConfig] = None, + ): + self._notifiers: List[Notifier] = [] + if slack: + self._notifiers.append(SlackNotifier(slack)) + if telegram: + self._notifiers.append(TelegramNotifier(telegram)) + + def send_file(self, filename: str, comment: Optional[str] = None): + for notifier in self._notifiers: + notifier.send_file(filename=filename, comment=comment) diff --git a/setup.py b/setup.py index a5d8ad4..604a8c2 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name="artifactory-cleanup", - version="1.0.18", + version="1.1.0", description="Rules and cleanup policies for Artifactory", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_cli.py b/tests/test_cli.py index 6b0dd0a..7e0fac0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from filecmp import cmp from artifactory_cleanup import ArtifactoryCleanupCLI +from artifactory_cleanup.notifiers import ReportNotifier def test_help(capsys): @@ -190,3 +191,81 @@ def test_display_format_default(capsys, shared_datadir, requests_mock): "DEBUG - we would delete 'repo-name-here/path/to/file/filename1.json' (11827853eed40e8b60f5d7e45f2a730915d7704d) - 528B\n" in stdout ) + + +@pytest.mark.usefixtures("requests_repo_name_here") +def test_send_report_to_notifiers(capsys, shared_datadir, requests_mock, tmp_path, monkeypatch): + output_file = tmp_path / "output.json" + call_args = {} + + def _send_file(self, filename: str, comment=None): + call_args["filename"] = filename + call_args["comment"] = comment + + monkeypatch.setattr(ReportNotifier, "send_file", _send_file) + _, code = ArtifactoryCleanupCLI.run( + [ + "ArtifactoryCleanupCLI", + "--config", + str(shared_datadir / "cleanup.yaml"), + "--load-rules", + str(shared_datadir / "myrule.py"), + "--output-format", + "json", + "--output", + str(output_file), + "--slack-token", + "xoxb-token", + "--slack-channel-id", + "C1234567890", + "--notify-comment", + "cleanup report", + ], + exit=False, + ) + stdout, stderr = capsys.readouterr() + assert code == 0, stdout + assert call_args == {"filename": str(output_file), "comment": "cleanup report"} + + +@pytest.mark.usefixtures("requests_repo_name_here") +def test_send_report_to_notifiers_from_config( + capsys, shared_datadir, requests_mock, tmp_path, monkeypatch +): + output_file = tmp_path / "output.json" + config_file = tmp_path / "cleanup-with-notifiers.yaml" + config_file.write_text( + ( + shared_datadir / "cleanup.yaml" + ).read_text() + + "\n" + + " slack_token: $SLACK_TOKEN\n" + + " slack_channel_id: C1234567890\n" + + " notify_comment: from-config\n", + encoding="utf-8", + ) + monkeypatch.setenv("SLACK_TOKEN", "xoxb-token") + call_args = {} + + def _send_file(self, filename: str, comment=None): + call_args["filename"] = filename + call_args["comment"] = comment + + monkeypatch.setattr(ReportNotifier, "send_file", _send_file) + _, code = ArtifactoryCleanupCLI.run( + [ + "ArtifactoryCleanupCLI", + "--config", + str(config_file), + "--load-rules", + str(shared_datadir / "myrule.py"), + "--output-format", + "json", + "--output", + str(output_file), + ], + exit=False, + ) + stdout, stderr = capsys.readouterr() + assert code == 0, stdout + assert call_args == {"filename": str(output_file), "comment": "from-config"}