Skip to content

Commit 03b8533

Browse files
dploegerDennis Ploeger
authored andcommitted
feat: First version
1 parent 7b97cd4 commit 03b8533

File tree

6 files changed

+413
-0
lines changed

6 files changed

+413
-0
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# ansible-loki-callback: An Ansible callback plugin that logs to a loki instance
2+
3+
## Requirements
4+
5+
* Python3
6+
* Ansible
7+
8+
## Installation
9+
10+
Download or clone the repository and install the requirements:
11+
12+
pip install -r requirements.txt
13+
14+
## Usage
15+
16+
Use the following environment variables to configure the plugin:
17+
18+
* LOKI_URL: URL to the Loki Push API endpoint (https://loki.example.com/api/v1/push)
19+
* LOKI_USERNAME: Username to authenticate at loki (optional)
20+
* LOKI_PASSWORD: Password to authenticate at loki (optional)
21+
* LOKI_DEFAULT_TAGS: A comma separated list of key:value pairs used for every log line (optional)
22+
* LOKI_ORG_ID: Loki organization id (optional)
23+
24+
Then set `ANSIBLE_CALLBACK_PLUGINS` to the path where you downloaded or cloned the repository to.
25+
26+
## Testing
27+
28+
The example directory contains a test playbook that can be used to test the callback plugin. Run it using
29+
30+
ANSIBLE_CALLBACK_PLUGINS="${PWD}" ansible-playbook -i example/inventory.yaml example/playbook.yaml -vvvvvv 2>/dev/null

example/inventory.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
unreachable:
2+
hosts:
3+
unreachable_host:
4+
ansible_host: 1.1.1.1

example/playbook.yaml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
- name: Test1
2+
hosts: 127.0.0.1
3+
connection: local
4+
gather_facts: no
5+
tasks:
6+
- name: Call github
7+
uri:
8+
url: 'https://github.com'
9+
10+
- name: Testdiff
11+
hosts: 127.0.0.1
12+
connection: local
13+
gather_facts: no
14+
tasks:
15+
- name: Create temp file
16+
tempfile: {}
17+
register: tempfile
18+
- name: Write tempfile
19+
copy:
20+
dest: "{{ tempfile.path }}"
21+
content: "test"
22+
23+
- name: TestFail
24+
hosts: 127.0.0.1
25+
connection: local
26+
gather_facts: no
27+
tasks:
28+
- name: Produce failure
29+
command: exit 1
30+
ignore_errors: yes
31+
32+
- name: Testskipped
33+
hosts: 127.0.0.1
34+
connection: local
35+
gather_facts: no
36+
tasks:
37+
- name: Skip it
38+
command: exit 1
39+
when: impossible is defined
40+
41+
- name: Testunreachable
42+
hosts: unreachable
43+
gather_facts: no
44+
ignore_errors: yes
45+
tasks:
46+
- name: Call github
47+
uri:
48+
url: 'https://github.com'

loki.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import datetime
2+
import logging
3+
import os
4+
5+
import jsonpickle
6+
import logging_loki
7+
from ansible.plugins.callback import CallbackBase
8+
9+
DOCUMENTATION = '''
10+
callback: loki
11+
type: loki
12+
short_description: Ansible output logging to loki
13+
version_added: 0.1.0
14+
description:
15+
- This plugin sends Ansible output to loki
16+
extends_documentation_fragment:
17+
- default_callback
18+
requirements:
19+
- set as loki in configuration
20+
options:
21+
result_format:
22+
name: Result format
23+
default: json
24+
description: Format used in results (will be set to json)
25+
pretty_results:
26+
name: Print results pretty
27+
default: False
28+
description: Whether to print results pretty (will be set to false)
29+
'''
30+
31+
32+
# For logging detailed data, we sometimes need to access protected object members
33+
# noinspection PyProtectedMember
34+
class CallbackModule(CallbackBase):
35+
CALLBACK_VERSION = 2.0
36+
CALLBACK_TYPE = 'loki'
37+
CALLBACK_NAME = 'loki'
38+
ALL_METRICS = ["changed", "custom", "dark", "failures", "ignored", "ok", "processed", "rescued", "skipped"]
39+
40+
def __init__(self):
41+
super().__init__()
42+
43+
if "LOKI_URL" not in os.environ:
44+
raise "LOKI_URL environment variable not specified."
45+
46+
auth = ()
47+
if "LOKI_USERNAME" in os.environ and "LOKI_PASSWORD" in os.environ:
48+
auth = (os.environ["LOKI_USERNAME"], os.environ["LOKI_PASSWORD"])
49+
50+
headers = {}
51+
if "LOKI_ORG_ID" in os.environ:
52+
headers["X-Scope-OrgID"] = os.environ["LOKI_ORG_ID"]
53+
54+
tags = {}
55+
if "LOKI_DEFAULT_TAGS" in os.environ:
56+
for tagvalue in os.environ["LOKI_DEFAULT_TAGS"].split(","):
57+
(tag, value) = tagvalue.split(":")
58+
tags[tag] = value
59+
60+
handler = logging_loki.LokiHandler(
61+
url=os.environ["LOKI_URL"],
62+
tags=tags,
63+
auth=auth,
64+
headers=headers,
65+
level_tag="level"
66+
)
67+
68+
self.logger = logging.getLogger("loki")
69+
self.logger.addHandler(handler)
70+
if self._display.verbosity == 0:
71+
self.logger.setLevel(logging.WARN)
72+
elif self._display.verbosity == 1:
73+
self.logger.setLevel(logging.INFO)
74+
else:
75+
self.logger.setLevel(logging.DEBUG)
76+
77+
self.set_option("result_format", "json")
78+
self.set_option("pretty_results", False)
79+
80+
def v2_playbook_on_start(self, playbook):
81+
self.playbook = os.path.join(playbook._basedir, playbook._file_name)
82+
self.run_timestamp = datetime.datetime.now().isoformat()
83+
self.logger.info(
84+
"Starting playbook %s" % self.playbook,
85+
extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp}}
86+
)
87+
self.logger.debug(
88+
jsonpickle.encode(playbook.__dict__),
89+
extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp, "dump": "playbook"}}
90+
)
91+
92+
def v2_playbook_on_play_start(self, play):
93+
self.current_play = play.name
94+
self.logger.info(
95+
"Starting play %s" % play.name,
96+
extra={"tags": {"playbook": self.playbook, "run_timestamp": self.run_timestamp, "play": self.current_play}}
97+
)
98+
self.logger.debug(
99+
jsonpickle.encode(play.__dict__),
100+
extra={
101+
"tags": {
102+
"playbook": self.playbook,
103+
"run_timestamp": self.run_timestamp,
104+
"play": self.current_play,
105+
"dump": "play"
106+
}
107+
}
108+
)
109+
110+
def v2_playbook_on_task_start(self, task, is_conditional):
111+
self.current_task = task.name
112+
self.logger.info(
113+
"Starting task %s" % self.current_task,
114+
extra={
115+
"tags": {
116+
"playbook": self.playbook,
117+
"run_timestamp": self.run_timestamp,
118+
"play": self.current_play,
119+
"task": self.current_task
120+
}
121+
}
122+
)
123+
self.logger.debug(
124+
jsonpickle.encode(task.__dict__),
125+
extra={
126+
"tags": {
127+
"playbook": self.playbook,
128+
"run_timestamp": self.run_timestamp,
129+
"play": self.current_play,
130+
"task": self.current_task,
131+
"dump": "task"
132+
}
133+
}
134+
)
135+
136+
def v2_runner_on_ok(self, result):
137+
self.logger.debug(
138+
"Task %s was successful" % result.task_name,
139+
extra={
140+
"tags": {
141+
"playbook": self.playbook,
142+
"run_timestamp": self.run_timestamp,
143+
"play": self.current_play,
144+
"task": self.current_task
145+
}
146+
}
147+
)
148+
self.logger.debug(
149+
self._dump_results(result._result),
150+
extra={
151+
"tags": {
152+
"playbook": self.playbook,
153+
"run_timestamp": self.run_timestamp,
154+
"play": self.current_play,
155+
"task": self.current_task,
156+
"dump": "runner"
157+
}
158+
}
159+
)
160+
161+
def v2_runner_on_failed(self, result, ignore_errors=False):
162+
level = logging.WARNING if ignore_errors else logging.ERROR
163+
self.logger.log(
164+
level,
165+
"Task %s was not successful%s: %s" % (
166+
self.current_task,
167+
", but errors were ignored" if ignore_errors else "",
168+
result._result['msg']
169+
),
170+
extra={
171+
"tags": {
172+
"playbook": self.playbook,
173+
"run_timestamp": self.run_timestamp,
174+
"play": self.current_play,
175+
"task": self.current_task
176+
}
177+
}
178+
)
179+
self.logger.debug(
180+
self._dump_results(result._result),
181+
extra={
182+
"tags": {
183+
"playbook": self.playbook,
184+
"run_timestamp": self.run_timestamp,
185+
"play": self.current_play,
186+
"task": self.current_task,
187+
"dump": "runner"
188+
}
189+
}
190+
)
191+
192+
def v2_runner_on_skipped(self, result):
193+
self.logger.info(
194+
"Task %s was skipped" % self.current_task,
195+
extra={
196+
"tags": {
197+
"playbook": self.playbook,
198+
"run_timestamp": self.run_timestamp,
199+
"play": self.current_play,
200+
"task": self.current_task
201+
}
202+
}
203+
)
204+
self.logger.debug(
205+
self._dump_results(result._result),
206+
extra={
207+
"tags": {
208+
"playbook": self.playbook,
209+
"run_timestamp": self.run_timestamp,
210+
"play": self.current_play,
211+
"task": self.current_task,
212+
"dump": "runner"
213+
}
214+
}
215+
)
216+
217+
def runner_on_unreachable(self, host, result):
218+
self.logger.error(
219+
"Host %s was unreachable for task %s" % (host, self.current_task),
220+
extra={
221+
"tags": {
222+
"playbook": self.playbook,
223+
"run_timestamp": self.run_timestamp,
224+
"play": self.current_play,
225+
"task": self.current_task
226+
}
227+
}
228+
)
229+
self.logger.debug(
230+
self._dump_results(result),
231+
extra={
232+
"tags": {
233+
"playbook": self.playbook,
234+
"run_timestamp": self.run_timestamp,
235+
"play": self.current_play,
236+
"task": self.current_task,
237+
"dump": "runner"
238+
}
239+
}
240+
)
241+
242+
def v2_playbook_on_no_hosts_matched(self):
243+
self.logger.error(
244+
"No hosts matched for playbook %s" % self.playbook,
245+
extra={
246+
"tags": {
247+
"playbook": self.playbook,
248+
"run_timestamp": self.run_timestamp
249+
}
250+
}
251+
)
252+
253+
def v2_on_file_diff(self, result):
254+
diff_list = result._result['diff']
255+
self.logger.info(
256+
"Task %s produced a diff:\n%s" % (self.current_task, self._get_diff(diff_list)),
257+
extra={
258+
"tags": {
259+
"playbook": self.playbook,
260+
"run_timestamp": self.run_timestamp,
261+
"play": self.current_play,
262+
"task": self.current_task
263+
}
264+
}
265+
)
266+
for diff in diff_list:
267+
self.logger.debug(
268+
self._serialize_diff(diff),
269+
extra={
270+
"tags": {
271+
"playbook": self.playbook,
272+
"run_timestamp": self.run_timestamp,
273+
"play": self.current_play,
274+
"task": self.current_task,
275+
"dump": "diff"
276+
}
277+
}
278+
)
279+
280+
def v2_playbook_on_stats(self, stats):
281+
summarize_metrics = {}
282+
host_metrics = {}
283+
for metric in self.ALL_METRICS:
284+
value = 0
285+
for host, host_value in stats.__dict__[metric].items():
286+
value += host_value
287+
if host not in host_metrics:
288+
host_metrics[host] = {}
289+
for m in self.ALL_METRICS:
290+
host_metrics[host][m] = 0
291+
host_metrics[host][metric] = host_value
292+
summarize_metrics[metric] = value
293+
self.logger.info(
294+
"Stats for playbook %s" % self.playbook,
295+
extra={
296+
"tags": {
297+
"playbook": self.playbook,
298+
"run_timestamp": self.run_timestamp,
299+
"stats_type": "summary"
300+
} | summarize_metrics
301+
}
302+
)
303+
for host in host_metrics:
304+
self.logger.debug(
305+
"Stats for playbook %s, host %s" % (self.playbook, host),
306+
extra={
307+
"tags": {
308+
"playbook": self.playbook,
309+
"run_timestamp": self.run_timestamp,
310+
"stats_type": "host"
311+
} | host_metrics[host]
312+
}
313+
)

0 commit comments

Comments
 (0)