diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c63616 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# ansible-loki-callback: An Ansible callback plugin that logs to a loki instance + +## Requirements + +* Python3 +* Ansible + +## Installation + +Download or clone the repository and install the requirements: + + pip install -r requirements.txt + +## Usage + +Use the following environment variables to configure the plugin: + +* LOKI_URL: URL to the Loki Push API endpoint (https://loki.example.com/api/v1/push) +* LOKI_USERNAME: Username to authenticate at loki (optional) +* LOKI_PASSWORD: Password to authenticate at loki (optional) +* LOKI_DEFAULT_TAGS: A comma separated list of key:value pairs used for every log line (optional) +* LOKI_ORG_ID: Loki organization id (optional) + +Then set `ANSIBLE_CALLBACK_PLUGINS` to the path where you downloaded or cloned the repository to. + +## Testing + +The example directory contains a test playbook that can be used to test the callback plugin. Run it using + + ANSIBLE_CALLBACK_PLUGINS="${PWD}" ansible-playbook -i example/inventory.yaml example/playbook.yaml -vvvvvv 2>/dev/null diff --git a/example/inventory.yaml b/example/inventory.yaml new file mode 100644 index 0000000..4cc04d8 --- /dev/null +++ b/example/inventory.yaml @@ -0,0 +1,4 @@ +unreachable: + hosts: + unreachable_host: + ansible_host: 1.1.1.1 \ No newline at end of file diff --git a/example/playbook.yaml b/example/playbook.yaml new file mode 100644 index 0000000..bdcd774 --- /dev/null +++ b/example/playbook.yaml @@ -0,0 +1,48 @@ +- name: Test1 + hosts: 127.0.0.1 + connection: local + gather_facts: no + tasks: + - name: Call github + uri: + url: 'https://github.com' + +- name: Testdiff + hosts: 127.0.0.1 + connection: local + gather_facts: no + tasks: + - name: Create temp file + tempfile: {} + register: tempfile + - name: Write tempfile + copy: + dest: "{{ tempfile.path }}" + content: "test" + +- name: TestFail + hosts: 127.0.0.1 + connection: local + gather_facts: no + tasks: + - name: Produce failure + command: exit 1 + ignore_errors: yes + +- name: Testskipped + hosts: 127.0.0.1 + connection: local + gather_facts: no + tasks: + - name: Skip it + command: exit 1 + when: impossible is defined + +- name: Testunreachable + hosts: unreachable + gather_facts: no + ignore_errors: yes + tasks: + - name: Call github + uri: + url: 'https://github.com' diff --git a/loki.py b/loki.py new file mode 100644 index 0000000..9d291d3 --- /dev/null +++ b/loki.py @@ -0,0 +1,313 @@ +import datetime +import logging +import os + +import jsonpickle +import logging_loki +from ansible.plugins.callback import CallbackBase + +DOCUMENTATION = ''' + callback: loki + type: loki + short_description: Ansible output logging to loki + version_added: 0.1.0 + description: + - This plugin sends Ansible output to loki + extends_documentation_fragment: + - default_callback + requirements: + - set as loki in configuration + options: + result_format: + name: Result format + default: json + description: Format used in results (will be set to json) + pretty_results: + name: Print results pretty + default: False + description: Whether to print results pretty (will be set to false) +''' + + +# For logging detailed data, we sometimes need to access protected object members +# noinspection PyProtectedMember +class CallbackModule(CallbackBase): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'loki' + CALLBACK_NAME = 'loki' + ALL_METRICS = ["changed", "custom", "dark", "failures", "ignored", "ok", "processed", "rescued", "skipped"] + + def __init__(self): + super().__init__() + + if "LOKI_URL" not in os.environ: + raise "LOKI_URL environment variable not specified." + + auth = () + if "LOKI_USERNAME" in os.environ and "LOKI_PASSWORD" in os.environ: + auth = (os.environ["LOKI_USERNAME"], os.environ["LOKI_PASSWORD"]) + + headers = {} + if "LOKI_ORG_ID" in os.environ: + headers["X-Scope-OrgID"] = os.environ["LOKI_ORG_ID"] + + tags = {} + if "LOKI_DEFAULT_TAGS" in os.environ: + for tagvalue in os.environ["LOKI_DEFAULT_TAGS"].split(","): + (tag, value) = tagvalue.split(":") + tags[tag] = value + + handler = logging_loki.LokiHandler( + url=os.environ["LOKI_URL"], + tags=tags, + auth=auth, + headers=headers, + level_tag="level" + ) + + self.logger = logging.getLogger("loki") + self.logger.addHandler(handler) + if self._display.verbosity == 0: + self.logger.setLevel(logging.WARN) + elif self._display.verbosity == 1: + self.logger.setLevel(logging.INFO) + else: + self.logger.setLevel(logging.DEBUG) + + self.set_option("result_format", "json") + self.set_option("pretty_results", False) + + def v2_playbook_on_start(self, playbook): + self.playbook = os.path.join(playbook._basedir, playbook._file_name) + self.run_timestamp = datetime.datetime.now().isoformat() + self.logger.info( + "Starting playbook %s" % self.playbook, + extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp}} + ) + self.logger.debug( + jsonpickle.encode(playbook.__dict__), + extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp, "dump": "playbook"}} + ) + + def v2_playbook_on_play_start(self, play): + self.current_play = play.name + self.logger.info( + "Starting play %s" % play.name, + extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp, "play": self.current_play}} + ) + self.logger.debug( + jsonpickle.encode(play.__dict__), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "dump": "play" + } + } + ) + + def v2_playbook_on_task_start(self, task, is_conditional): + self.current_task = task.name + self.logger.info( + "Starting task %s" % self.current_task, + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + self.logger.debug( + jsonpickle.encode(task.__dict__), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "task" + } + } + ) + + def v2_runner_on_ok(self, result): + self.logger.debug( + "Task %s was successful" % result.task_name, + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + self.logger.debug( + self._dump_results(result._result), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "runner" + } + } + ) + + def v2_runner_on_failed(self, result, ignore_errors=False): + level = logging.WARNING if ignore_errors else logging.ERROR + self.logger.log( + level, + "Task %s was not successful%s: %s" % ( + self.current_task, + ", but errors were ignored" if ignore_errors else "", + result._result['msg'] + ), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + self.logger.debug( + self._dump_results(result._result), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "runner" + } + } + ) + + def v2_runner_on_skipped(self, result): + self.logger.info( + "Task %s was skipped" % self.current_task, + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + self.logger.debug( + self._dump_results(result._result), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "runner" + } + } + ) + + def runner_on_unreachable(self, host, result): + self.logger.error( + "Host %s was unreachable for task %s" % (host, self.current_task), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + self.logger.debug( + self._dump_results(result), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "runner" + } + } + ) + + def v2_playbook_on_no_hosts_matched(self): + self.logger.error( + "No hosts matched for playbook %s" % self.playbook, + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp + } + } + ) + + def v2_on_file_diff(self, result): + diff_list = result._result['diff'] + self.logger.info( + "Task %s produced a diff:\n%s" % (self.current_task, self._get_diff(diff_list)), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task + } + } + ) + for diff in diff_list: + self.logger.debug( + self._serialize_diff(diff), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "play": self.current_play, + "task": self.current_task, + "dump": "diff" + } + } + ) + + def v2_playbook_on_stats(self, stats): + summarize_metrics = {} + host_metrics = {} + for metric in self.ALL_METRICS: + value = 0 + for host, host_value in stats.__dict__[metric].items(): + value += host_value + if host not in host_metrics: + host_metrics[host] = {} + for m in self.ALL_METRICS: + host_metrics[host][m] = 0 + host_metrics[host][metric] = host_value + summarize_metrics[metric] = value + self.logger.info( + "Stats for playbook %s" % self.playbook, + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "stats_type": "summary" + } | summarize_metrics + } + ) + for host in host_metrics: + self.logger.debug( + "Stats for playbook %s, host %s" % (self.playbook, host), + extra={ + "tags": { + "playbook": self.playbook, + "run_timestamp": self.run_timestamp, + "stats_type": "host" + } | host_metrics[host] + } + ) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..66a2c30 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +ansible==10.0.1 +ansible-core==2.17.0 +cffi==1.16.0 +cryptography==42.0.8 +Jinja2==3.1.4 +MarkupSafe==2.1.5 +packaging==24.1 +pycparser==2.22 +PyYAML==6.0.1 +resolvelib==1.0.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef3b98a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +certifi==2024.6.2 +charset-normalizer==3.3.2 +idna==3.7 +jsonpickle==3.2.1 +python-logging-loki @ git+https://github.com/waylayio/python-logging-loki.git@69428186d64a560eda20701f0910b86bedf0ebcf +requests==2.32.3 +rfc3339==6.2 +urllib3==2.2.1