Merge ~aluria/charm-prometheus-blackbox-exporter-peer:rewrite-move-complexity into charm-prometheus-blackbox-exporter-peer:master

Proposed by Alvaro Uria
Status: Superseded
Proposed branch: ~aluria/charm-prometheus-blackbox-exporter-peer:rewrite-move-complexity
Merge into: charm-prometheus-blackbox-exporter-peer:master
Diff against target: 1183 lines (+881/-117)
25 files modified
.gitignore (+22/-0)
Makefile (+56/-0)
config.yaml (+14/-13)
dev/null (+0/-94)
icon.svg (+12/-0)
interfaces/.empty (+1/-0)
layer.yaml (+12/-3)
layers/.empty (+1/-0)
lib/lib_bb_peer_exporter.py (+148/-0)
lib/lib_network.py (+55/-0)
metadata.yaml (+17/-7)
reactive/prometheus_blackbox_exporter_peer.py (+85/-0)
tests/functional/bundle.yaml.j2 (+13/-0)
tests/functional/conftest.py (+68/-0)
tests/functional/juju_tools.py (+71/-0)
tests/functional/requirements.txt (+7/-0)
tests/functional/test_deploy.py (+131/-0)
tests/unit/conftest.py (+60/-0)
tests/unit/requirements.txt (+5/-0)
tests/unit/samples/expected.multi (+1/-0)
tests/unit/samples/expected.single (+1/-0)
tests/unit/test_lib_bb_peer_exporter.py (+12/-0)
tests/unit/test_lib_network.py (+40/-0)
tox.ini (+48/-0)
wheelhouse.txt (+1/-0)
Reviewer Review Type Date Requested Status
Peter Sabaini (community) Approve
Canonical IS Reviewers Pending
Canonical IS Reviewers Pending
Review via email: mp+374648@code.launchpad.net

This proposal has been superseded by a proposal from 2020-04-16.

To post a comment you must log in.
bf9bd8b... by Alvaro Uria

Improve lib_network unit test

Revision history for this message
Alvaro Uria (aluria) wrote :

Hi! I've addressed the previously suggested fixes at [1].

OTOH, the reactive script has been rewritten so it only interacts with the "blackbox-exporter" relation. It's up to prometheus2-charm to create the icmp probes.

Although this charm exposes a "icmp_jumbo" module by default, prometheus2-charm won't configure probes using this module for now.

1. https://code.launchpad.net/~aluria/prometheus-blackbox-exporter-charm/+git/prometheus-blackbox-exporter-charm/+merge/374432

Revision history for this message
Peter Sabaini (peter-sabaini) wrote :

Thanks, good stuff! I've added an inline style nit comment but otherwise +1

review: Approve
Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

This merge proposal is being monitored by mergebot. Change the status to Approved to merge.

Revision history for this message
🤖 Canonical IS Merge Bot (canonical-is-mergebot) wrote :

Unable to determine commit message from repository - please click "Set commit message" and enter the commit message manually.

Unmerged commits

bf9bd8b... by Alvaro Uria

Improve lib_network unit test

cce865f... by Alvaro Uria

Fixes per initial review

8633d54... by Alvaro Uria

Update unit tests

ca94a30... by Alvaro Uria

Rewrite the blackbox-exporter relation

 * Each unit exposes its available networks with the blackbox-exporter
 remote units (juju config updates the relation info)
 * lib_network uses psutil everywhere instead of a mix of psutil and
 netifaces (ipaddress module is still used for validation)
 * module for "icmp_jump" added to the default config

1901aed... by Alvaro Uria

Rename p-bb-peer-exporter to p-bb-exporter-peer

371fd4b... by Alvaro Uria

Rewrite charm using template-python-pytest

 * Cloned template-python-pytest
 * Moved the reactive script helpers to lib_bb_peer_exporter (generic)
 and lib_network (network related helpers)
 * Created minimum unit (for the libs) and functional tests
 * wheelhouse.txt installs psutil as well as netifaces and pyroute2.
 * Linting now runs flake8-docstrings, flake8-import-order and other
 extra checks. All scripts have been updated following parser
 recommendations.
 * Better use of the peer-discovery interface. Available (and transient)
 states when units join or leave a peer relation are used to trigger
 config changes.

a458c6a... by Andrea Ieri

Noop readability refactor

881b419... by Andrea Ieri

Lookup source ips for all probes and provide the address over the relation

64d1422... by Andrea Ieri

Filter out uninteresting IPs and interfaces

413057f... by Andrea Ieri

Publish the external IP in case we're running cross-model relations

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000..32e2995
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,22 @@
7+# Byte-compiled / optimized / DLL files
8+__pycache__/
9+*.py[cod]
10+*$py.class
11+
12+# Log files
13+*.log
14+
15+.tox/
16+.coverage
17+
18+# vi
19+.*.swp
20+
21+# pycharm
22+.idea/
23+
24+# version data
25+repo-info
26+
27+# reports
28+report/*
29diff --git a/Makefile b/Makefile
30new file mode 100644
31index 0000000..eb95ca8
32--- /dev/null
33+++ b/Makefile
34@@ -0,0 +1,56 @@
35+PROJECTPATH = $(dir $(realpath $(firstword $(MAKEFILE_LIST))))
36+DIRNAME = $(notdir $(PROJECTPATH:%/=%))
37+
38+ifndef CHARM_BUILD_DIR
39+ CHARM_BUILD_DIR := /tmp/$(DIRNAME)-builds
40+ $(warning Warning CHARM_BUILD_DIR was not set, defaulting to $(CHARM_BUILD_DIR))
41+endif
42+
43+help:
44+ @echo "This project supports the following targets"
45+ @echo ""
46+ @echo " make help - show this text"
47+ @echo " make lint - run flake8"
48+ @echo " make test - run the unittests and lint"
49+ @echo " make unittest - run the tests defined in the unittest subdirectory"
50+ @echo " make functional - run the tests defined in the functional subdirectory"
51+ @echo " make release - build the charm"
52+ @echo " make clean - remove unneeded files"
53+ @echo ""
54+
55+lint:
56+ @echo "Running flake8"
57+ @tox -e lint
58+
59+test: lint unittest functional
60+
61+unittest:
62+ @tox -e unit
63+
64+functional: build
65+ @PYTEST_KEEP_MODEL=$(PYTEST_KEEP_MODEL) \
66+ PYTEST_CLOUD_NAME=$(PYTEST_CLOUD_NAME) \
67+ PYTEST_CLOUD_REGION=$(PYTEST_CLOUD_REGION) \
68+ CHARM_BUILD_DIR=$(CHARM_BUILD_DIR) \
69+ tox -e functional
70+
71+build:
72+ @echo "Building charm to base directory $(CHARM_BUILD_DIR)"
73+ @CHARM_LAYERS_DIR=./layers \
74+ CHARM_INTERFACES_DIR=./interfaces \
75+ TERM=linux \
76+ CHARM_BUILD_DIR=$(CHARM_BUILD_DIR) \
77+ charm build . --force
78+
79+release: clean build
80+ @echo "Charm is built at $(CHARM_BUILD_DIR)"
81+
82+clean:
83+ @echo "Cleaning files"
84+ @if [ -d $(CHARM_BUILD_DIR) ] ; then rm -r $(CHARM_BUILD_DIR) ; fi
85+ @if [ -d .tox ] ; then rm -r .tox ; fi
86+ @if [ -d .pytest_cache ] ; then rm -r .pytest_cache ; fi
87+ @find . -iname __pycache__ -exec rm -r {} +
88+
89+# The targets below don't depend on a file
90+.PHONY: lint test unittest functional build release clean help
91diff --git a/config.yaml b/config.yaml
92index d1dc24d..3a9977e 100644
93--- a/config.yaml
94+++ b/config.yaml
95@@ -1,21 +1,22 @@
96 options:
97- snap_channel:
98- default: "stable"
99- type: string
100- description: |
101- If install_method is set to "snap" this option controlls channel name.
102- Supported values are: "stable", "candidate", "beta" and "edge"
103 modules:
104 default: |
105- http_2xx:
106- prober: http
107- timeout: 10s
108- tcp_connect:
109- prober: tcp
110- timeout: 10s
111 icmp:
112 prober: icmp
113 timeout: 10s
114+ icmp:
115+ preferred_ip_protocol: "ip4"
116+ payload_size: 1472
117+ icmp_jumbo:
118+ prober: icmp
119+ timeout: 10s
120+ icmp:
121+ preferred_ip_protocol: "ip4"
122+ payload_size: 8972
123 type: string
124 description: |
125- Blackbox exporter configuratin in raw YAML format
126+ Blackbox exporter configuration in raw YAML format
127+ scrape-interval:
128+ type: string
129+ default: "60s"
130+ description: Set the blackbox exporter scrape jobs custom interval.
131diff --git a/icon.svg b/icon.svg
132new file mode 100644
133index 0000000..ffa6296
134--- /dev/null
135+++ b/icon.svg
136@@ -0,0 +1,12 @@
137+<?xml version="1.0" encoding="UTF-8"?>
138+<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
139+ <!-- Generator: Sketch 45.2 (43514) - http://www.bohemiancoding.com/sketch -->
140+ <title>prometheus</title>
141+ <desc>Created with Sketch.</desc>
142+ <defs></defs>
143+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
144+ <g id="prometheus" fill-rule="nonzero" fill="#B8B8B8">
145+ <path d="M50.0004412,3.12178676e-15 C22.3871247,3.12178676e-15 0,22.3848726 0,49.9995588 C0,77.6133626 22.3871247,100 50.0004412,100 C77.6137577,100 100,77.6133626 100,49.9995588 C100,22.3848726 77.6128753,-1.77635684e-15 50.0004412,3.12178676e-15 Z M49.8864141,88.223871 C42.8334797,88.223871 37.1152482,83.6412396 37.1152482,77.9899953 L62.6575799,77.9899953 C62.6575799,83.6404692 56.9393484,88.223871 49.8864141,88.223871 Z M70.9802642,74.6007896 L28.7901876,74.6007896 L28.7901876,67.159792 L70.9810563,67.159792 L70.9810563,74.6007896 L70.9802642,74.6007896 Z M70.8289715,63.3298895 L28.9105881,63.3298895 C28.771177,63.1734883 28.6285974,63.0193985 28.493939,62.860686 C24.1753633,57.7603128 23.1582959,55.0976406 22.1705366,52.3841188 C22.1539023,52.2947467 27.4071661,53.4280774 31.1324525,54.2432125 C31.1324525,54.2432125 33.0493552,54.6746641 35.8518351,55.1716037 C33.1610425,52.1036753 31.5633596,48.2036619 31.5633596,44.2173581 C31.5633596,35.4658266 38.4642091,27.8183486 35.974612,21.6370353 C38.397672,21.8288772 40.9894511,26.6110549 41.164507,34.0882636 C43.740444,30.6258652 44.8185037,24.3027893 44.8185037,20.4258893 C44.8185037,16.4118494 47.5378123,11.7490913 50.257913,11.5896084 C47.8332688,15.4765242 50.886055,18.8087166 53.5998189,27.0748652 C54.6176783,30.1797752 54.4877725,35.4049611 55.2735442,38.7186628 C55.5341479,31.8362408 56.7508266,21.794207 61.2397056,18.3271859 C59.2594343,22.6933211 61.5327858,28.1565758 63.0876948,30.7830368 C65.5963025,35.020507 67.1171509,38.2309685 67.1171509,44.302878 C67.1171509,48.3739312 65.5717472,52.2069155 62.964918,55.2031922 C65.9289881,54.6623369 67.9757966,54.1746426 67.9757966,54.1746426 L77.6014994,52.3479077 C77.6022915,52.3471373 76.2034279,57.9421388 70.8289715,63.3298895 Z" id="path3023"></path>
146+ </g>
147+ </g>
148+</svg>
149\ No newline at end of file
150diff --git a/interfaces/.empty b/interfaces/.empty
151new file mode 100644
152index 0000000..792d600
153--- /dev/null
154+++ b/interfaces/.empty
155@@ -0,0 +1 @@
156+#
157diff --git a/layer.yaml b/layer.yaml
158index ddd95fc..a161477 100644
159--- a/layer.yaml
160+++ b/layer.yaml
161@@ -1,6 +1,15 @@
162-includes: ['layer:basic', 'interface:http', 'layer:snap']
163-repo: 'https://git.launchpad.net/prometheus-blackbox-exporter-charm'
164-ignore: ['.*.swp' ]
165+# exclude the interfaces and layers folders we use for submodules
166+exclude:
167+ - interfaces
168+ - layers
169+# include required layers here
170+includes:
171+ - 'layer:basic'
172+ - 'layer:status'
173+ - 'interface:http'
174+ - 'interface:juju-info'
175+ - 'interface:peer-discovery'
176+repo: 'https://git.launchpad.net/charm-prometheus-blackbox-exporter-peer'
177 options:
178 basic:
179 use_venv: true
180diff --git a/layers/.empty b/layers/.empty
181new file mode 100644
182index 0000000..792d600
183--- /dev/null
184+++ b/layers/.empty
185@@ -0,0 +1 @@
186+#
187diff --git a/lib/lib_bb_peer_exporter.py b/lib/lib_bb_peer_exporter.py
188new file mode 100644
189index 0000000..48203d8
190--- /dev/null
191+++ b/lib/lib_bb_peer_exporter.py
192@@ -0,0 +1,148 @@
193+"""General helpers."""
194+import os
195+import subprocess
196+
197+from charmhelpers import fetch
198+from charmhelpers.core import hookenv, host, unitdata
199+from charmhelpers.core.templating import render
200+
201+from charms.reactive.helpers import any_file_changed, data_changed
202+
203+import lib_network
204+
205+import yaml
206+
207+
208+APT_PKG_NAME = 'prometheus-blackbox-exporter'
209+SVC_NAME = 'prometheus-blackbox-exporter'
210+SVC_PATH = os.path.join('/usr/bin', SVC_NAME)
211+PORT_DEF = 9115 # blackbox_exporter default port
212+BLACKBOX_EXPORTER_YML_TMPL = 'blackbox.yaml.j2'
213+CONF_FILE_PATH = '/etc/prometheus/blackbox.yml'
214+
215+
216+class BBPeerExporterError(Exception):
217+ """Handle exceptions encountered in BBPeerExporterHelper."""
218+
219+ pass
220+
221+
222+class BBPeerExporterHelper():
223+ """General helpers."""
224+
225+ def __init__(self):
226+ """Load config.yaml."""
227+ self.charm_config = hookenv.config()
228+
229+ @property
230+ def is_config_changed(self):
231+ """Verify and update checksum if config has changed."""
232+ return data_changed('blackbox-peer-exporter.config', self.charm_config)
233+
234+ def bbexporter_relation_data_changed(self, keymap):
235+ """Verify and update checksum if provides relation data has changed."""
236+ return data_changed('blackbox-exporter.relation_data', keymap)
237+
238+ @property
239+ def is_blackbox_exporter_relation_enabled(self):
240+ """Verify if the blackbox-export relation exists."""
241+ kv = unitdata.kv()
242+ return kv.get('blackbox_exporter', False)
243+
244+ @property
245+ def enable_blackbox_exporter_relation(self):
246+ """Enable the blackbox-export flag for one time functions."""
247+ kv = unitdata.kv()
248+ kv.set('blackbox_exporter', True)
249+
250+ @property
251+ def disable_blackbox_exporter_relation(self):
252+ """Disable the blackbox-export flag for one time functions."""
253+ kv = unitdata.kv()
254+ kv.set('blackbox_exporter', False)
255+
256+ @property
257+ def modules(self):
258+ """Return the modules config parameter."""
259+ return self.charm_config['modules']
260+
261+ @property
262+ def scrape_interval(self):
263+ """Return the scrape-interval config parameter."""
264+ return self.charm_config['scrape-interval']
265+
266+ @property
267+ def port_def(self):
268+ """Return the port exposed by blackbox-exporter."""
269+ return PORT_DEF
270+
271+ @property
272+ def templates_changed(self):
273+ """Verify if any stored template has changed."""
274+ return any_file_changed(['templates/{}'.format(tmpl)
275+ for tmpl in [BLACKBOX_EXPORTER_YML_TMPL]])
276+
277+ def install_packages(self):
278+ """Install the APT package and sets Linux capabilities."""
279+ fetch.install(APT_PKG_NAME, fatal=True)
280+ cmd = ["setcap", "cap_net_raw+ep", SVC_PATH]
281+ try:
282+ subprocess.check_output(cmd)
283+ except subprocess.CalledProcessError as error:
284+ hookenv.log('unable to set linux capabilities: {}'.format(str(error)),
285+ hookenv.ERROR)
286+ raise BBPeerExporterError('Unable to set linux capabilities')
287+
288+ def render_modules(self):
289+ """Generate /etc/prometheus/blackbox.yml from the template."""
290+ try:
291+ modules = yaml.safe_load(self.modules)
292+ if 'modules' in modules:
293+ modules = modules['modules']
294+ except (yaml.parser.ParserError, yaml.scanner.ScannerError, TypeError) as error:
295+ hookenv.log('error retrieving modules yaml config: {}'.format(str(error)),
296+ hookenv.ERROR)
297+ # Could not render modules
298+ return False
299+
300+ context = {'modules': yaml.safe_dump(modules, default_flow_style=False)}
301+ render(source=BLACKBOX_EXPORTER_YML_TMPL, target=CONF_FILE_PATH, context=context)
302+ hookenv.open_port(PORT_DEF)
303+ return True
304+
305+ def restart_bbexporter(self):
306+ """Restart the exporter daemon."""
307+ if not host.service_running(SVC_NAME):
308+ hookenv.log('Starting {}...'.format(SVC_NAME))
309+ host.service_start(SVC_NAME)
310+ else:
311+ hookenv.log('Restarting {}, config file changed...'.format(SVC_NAME))
312+ host.service_restart(SVC_NAME)
313+
314+ def cleanup_blackbox_exporter_relation(self, rid):
315+ """Remove network data previously shared with Prometheus."""
316+ relation_keys = ["ip_address", "port", "scrape_interval", "unit-principal",
317+ "unit-networks",
318+ ]
319+ relation_settings = {relkey: None for relkey in relation_keys}
320+ # Resets information previously shared with the "prometheus" application(s)
321+ hookenv.relation_set(relation_id=rid, relation_settings=relation_settings)
322+
323+ def configure_blackbox_exporter_relation(self):
324+ """Retrieve network data and share it with Prometheus."""
325+ relation_settings = {
326+ 'private-address': hookenv.unit_get('private-address'),
327+ 'port': self.port_def,
328+ 'scrape_interval': self.scrape_interval,
329+ 'unit-principal': hookenv.principal_unit(),
330+ 'unit-networks': lib_network.get_unit_ipv4_networks(),
331+ }
332+
333+ if not self.bbexporter_relation_data_changed(relation_settings):
334+ return
335+
336+ # Shares information with the "prometheus" application
337+ for rel_id in hookenv.relation_ids('blackbox-exporter'):
338+ relation_settings['ip_address'] = \
339+ hookenv.ingress_address(rid=rel_id, unit=hookenv.local_unit())
340+ hookenv.relation_set(relation_id=rel_id, relation_settings=relation_settings)
341diff --git a/lib/lib_network.py b/lib/lib_network.py
342new file mode 100644
343index 0000000..974bf9d
344--- /dev/null
345+++ b/lib/lib_network.py
346@@ -0,0 +1,55 @@
347+"""Network helpers."""
348+import ipaddress
349+import json
350+import re
351+import socket
352+
353+import psutil
354+
355+
356+IFACE_WHITELIST_PATTERN = "^(en|eth|bond|br)"
357+IFACE_WHITELIST_RE = re.compile(IFACE_WHITELIST_PATTERN)
358+
359+
360+def get_unit_ipv4_networks():
361+ """Return a list of IPv4 network blocks available on the host (json format).
362+
363+ :returns: dict<dict>
364+ """
365+ networks = {}
366+ # whitelist of available interfaces
367+ unit_ifaces = {iface_name: iface_info
368+ for iface_name, iface_info in psutil.net_if_addrs().items()
369+ if IFACE_WHITELIST_RE.search(iface_name)}
370+ iface_names = unit_ifaces.keys()
371+ unit_ifaces_stats = {iface_name: iface_stats
372+ for iface_name, iface_stats in psutil.net_if_stats().items()
373+ if iface_name in iface_names}
374+ for iface_name, iface_info in unit_ifaces.items():
375+ # Retrieve the primary IPv4 address (no VIPs or secondary IPs)
376+ # we don't want floating IPs to be configured for probes
377+ ipv4_addrs = [real_ip for real_ip in iface_info
378+ if real_ip.family == socket.AF_INET]
379+ # No IPv4 addresses configured
380+ if not ipv4_addrs:
381+ continue
382+
383+ ip_addr = ipv4_addrs[0]
384+ network = "{}/{}".format(ip_addr.address, ip_addr.netmask)
385+ ip_ipv4 = ipaddress.IPv4Interface(network)
386+ invalid = any((ip_ipv4.is_multicast, ip_ipv4.is_reserved,
387+ ip_ipv4.is_link_local, ip_ipv4.is_loopback))
388+ if invalid:
389+ continue
390+
391+ mtu = 1500
392+ if iface_name in unit_ifaces_stats:
393+ mtu = unit_ifaces_stats[iface_name].mtu
394+
395+ networks[str(ip_ipv4.network)] = {
396+ "iface": iface_name,
397+ "ip": str(ip_ipv4.ip),
398+ "net": str(ip_ipv4.network),
399+ "mtu": str(mtu),
400+ }
401+ return json.dumps(networks)
402diff --git a/metadata.yaml b/metadata.yaml
403index 4f9228b..fccea60 100644
404--- a/metadata.yaml
405+++ b/metadata.yaml
406@@ -1,15 +1,25 @@
407-name: prometheus-blackbox-exporter
408-summary: Blackbox exporter for Prometheus
409-maintainer: Jacek Nykis <jacek.nykis@canonical.com>
410+name: prometheus-blackbox-exporter-peer
411+display-name: Prometheus Blackbox Exporter Peer
412+summary: Blackbox peer exporter for Prometheus
413+maintainer: Llama (LMA) Charmers <llama-charmers@lists.launchpad.net>
414 description: |
415- The blackbox exporter allows blackbox probing of
416- endpoints over HTTP, HTTPS, DNS, TCP and ICMP.
417+ The blackbox peer exporter allows blackbox probing of
418+ endpoints over HTTP, HTTPS, DNS, TCP and ICMP. This
419+ charm allows all units to probe their peers, whereas
420+ the prometheus-blackbox-exporter charm deploys a single
421+ unit that can probe external endpoints.
422 tags:
423 - monitoring
424 series:
425- - xenial
426 - bionic
427-subordinate: false
428+subordinate: true
429 provides:
430 blackbox-exporter:
431 interface: http
432+requires:
433+ general-info:
434+ interface: juju-info
435+ scope: container
436+peers:
437+ blackbox-peer:
438+ interface: peer-discovery
439diff --git a/reactive/prometheus-blackbox-exporter.py b/reactive/prometheus-blackbox-exporter.py
440deleted file mode 100644
441index a5d792a..0000000
442--- a/reactive/prometheus-blackbox-exporter.py
443+++ /dev/null
444@@ -1,94 +0,0 @@
445-import yaml
446-
447-from charmhelpers.core import host, hookenv
448-from charmhelpers.core.templating import render
449-from charms.reactive import (
450- when, when_not, set_state, remove_state
451-)
452-from charms.reactive.helpers import any_file_changed, data_changed
453-from charms.layer import snap
454-
455-
456-SNAP_NAME = 'prometheus-blackbox-exporter'
457-SVC_NAME = 'snap.prometheus-blackbox-exporter.daemon'
458-PORT_DEF = 9115
459-BLACKBOX_EXPORTER_YML_TMPL = 'blackbox.yaml.j2'
460-CONF_FILE_PATH = '/var/snap/prometheus-blackbox-exporter/current/blackbox.yml'
461-
462-
463-def templates_changed(tmpl_list):
464- return any_file_changed(['templates/{}'.format(x) for x in tmpl_list])
465-
466-
467-@when_not('blackbox-exporter.installed')
468-def install_packages():
469- hookenv.status_set('maintenance', 'Installing software')
470- config = hookenv.config()
471- channel = config.get('snap_channel', 'stable')
472- snap.install(SNAP_NAME, channel=channel, force_dangerous=False)
473- set_state('blackbox-exporter.installed')
474- set_state('blackbox-exporter.do-check-reconfig')
475-
476-
477-def get_modules():
478- config = hookenv.config()
479- try:
480- modules = yaml.safe_load(config.get('modules'))
481- except:
482- return None
483-
484- if 'modules' in modules:
485- return yaml.safe_dump(modules['modules'], default_flow_style=False)
486- else:
487- return yaml.safe_dump(modules, default_flow_style=False)
488-
489-
490-@when('blackbox-exporter.installed')
491-@when('blackbox-exporter.do-reconfig-yaml')
492-def write_blackbox_exporter_config_yaml():
493- modules = get_modules()
494- render(source=BLACKBOX_EXPORTER_YML_TMPL,
495- target=CONF_FILE_PATH,
496- context={'modules': modules}
497- )
498- hookenv.open_port(PORT_DEF)
499- set_state('blackbox-exporter.do-restart')
500- remove_state('blackbox-exporter.do-reconfig-yaml')
501-
502-
503-@when('blackbox-exporter.started')
504-def check_config():
505- set_state('blackbox-exporter.do-check-reconfig')
506-
507-
508-@when('blackbox-exporter.do-check-reconfig')
509-def check_reconfig_blackbox_exporter():
510- config = hookenv.config()
511-
512- if data_changed('blackbox-exporter.config', config):
513- set_state('blackbox-exporter.do-reconfig-yaml')
514-
515- if templates_changed([BLACKBOX_EXPORTER_YML_TMPL]):
516- set_state('blackbox-exporter.do-reconfig-yaml')
517-
518- remove_state('blackbox-exporter.do-check-reconfig')
519-
520-
521-@when('blackbox-exporter.do-restart')
522-def restart_blackbox_exporter():
523- if not host.service_running(SVC_NAME):
524- hookenv.log('Starting {}...'.format(SVC_NAME))
525- host.service_start(SVC_NAME)
526- else:
527- hookenv.log('Restarting {}, config file changed...'.format(SVC_NAME))
528- host.service_restart(SVC_NAME)
529- hookenv.status_set('active', 'Ready')
530- set_state('blackbox-exporter.started')
531- remove_state('blackbox-exporter.do-restart')
532-
533-
534-# Relations
535-@when('blackbox-exporter.started')
536-@when('blackbox-exporter.available') # Relation name is "blackbox-exporter"
537-def configure_blackbox_exporter_relation(target):
538- target.configure(PORT_DEF)
539diff --git a/reactive/prometheus_blackbox_exporter_peer.py b/reactive/prometheus_blackbox_exporter_peer.py
540new file mode 100644
541index 0000000..0fa608a
542--- /dev/null
543+++ b/reactive/prometheus_blackbox_exporter_peer.py
544@@ -0,0 +1,85 @@
545+"""Reactive script describing the prometheus-blackbox-exporter-peer behavior."""
546+
547+from charmhelpers.core import hookenv
548+
549+from charms.layer import status
550+from charms.reactive import (
551+ clear_flag, set_flag, when, when_not
552+)
553+
554+from lib_bb_peer_exporter import BBPeerExporterError, BBPeerExporterHelper
555+
556+helper = BBPeerExporterHelper()
557+
558+
559+@when_not('prometheus-blackbox-exporter-peer.installed')
560+def install_packages():
561+ """Install APT package or get blocked until user action."""
562+ status.maintenance('Installing software')
563+ try:
564+ helper.install_packages()
565+ set_flag('prometheus-blackbox-exporter-peer.installed')
566+ set_flag('prometheus-blackbox-exporter-peer.do-check-reconfig')
567+ except BBPeerExporterError as error:
568+ status.blocked(error)
569+
570+
571+@when('prometheus-blackbox-exporter-peer.installed')
572+@when('prometheus-blackbox-exporter-peer.do-reconfig-yaml')
573+def write_blackbox_exporter_config_yaml():
574+ """Generate /etc/prometheus/blackbox.yml."""
575+ if not helper.render_modules():
576+ status.blocked('Juju config: modules could not be loaded')
577+ return
578+ set_flag('prometheus-blackbox-exporter-peer.do-restart')
579+ clear_flag('prometheus-blackbox-exporter-peer.do-reconfig-yaml')
580+
581+
582+@when('prometheus-blackbox-exporter-peer.started')
583+def check_config():
584+ """Trigger a config check."""
585+ set_flag('prometheus-blackbox-exporter-peer.do-check-reconfig')
586+
587+
588+@when('prometheus-blackbox-exporter-peer.do-check-reconfig')
589+def check_reconfig_blackbox_exporter():
590+ """Trigger a blackbox.yml config regeneration on changes."""
591+ if helper.is_config_changed or helper.templates_changed:
592+ set_flag('prometheus-blackbox-exporter-peer.do-reconfig-yaml')
593+
594+ clear_flag('prometheus-blackbox-exporter-peer.do-check-reconfig')
595+
596+
597+@when('prometheus-blackbox-exporter-peer.do-restart')
598+def restart_blackbox_exporter():
599+ """Restart the blackbox-exporter daemon."""
600+ helper.restart_bbexporter()
601+ status.active('Ready')
602+ set_flag('prometheus-blackbox-exporter-peer.started')
603+ clear_flag('prometheus-blackbox-exporter-peer.do-restart')
604+
605+
606+@when('blackbox-exporter.available')
607+def blackbox_peer_and_exporter_any_hook():
608+ """First time the prometheus2:blackbox-exporter relation is seen."""
609+ if not helper.is_blackbox_exporter_relation_enabled or helper.is_config_changed:
610+ helper.enable_blackbox_exporter_relation
611+ hookenv.log('Running blackbox exporter relation.')
612+ status.maintenance('Running blackbox exporter relation.')
613+ helper.configure_blackbox_exporter_relation()
614+ status.active('Ready')
615+
616+
617+@when_not('blackbox-exporter.available')
618+def blackbox_peer_and_no_exporter_any_hook():
619+ """When the prometheus2:blackbox-exporter relation is gone."""
620+ if helper.is_blackbox_exporter_relation_enabled:
621+ helper.disable_blackbox_exporter_relation
622+ rid, hook = hookenv.relation_id(), hookenv.hook_name()
623+ if hook != 'blackbox-exporter-relation-departed' or rid is None:
624+ return
625+
626+ hookenv.log('Cleaning blackbox exporter relation data.')
627+ status.maintenance('Cleaning blackbox exporter relation data.')
628+ helper.cleanup_blackbox_exporter_relation(rid)
629+ status.active('Ready')
630diff --git a/tests/functional/bundle.yaml.j2 b/tests/functional/bundle.yaml.j2
631new file mode 100644
632index 0000000..e52b841
633--- /dev/null
634+++ b/tests/functional/bundle.yaml.j2
635@@ -0,0 +1,13 @@
636+applications:
637+ {{ ubuntu_appname }}:
638+ series: {{ series }}
639+ charm: cs:ubuntu
640+ num_units: 2
641+
642+ {{ bb_appname }}:
643+ series: {{ series }}
644+ charm: {{ charm_path }}
645+
646+relations:
647+ - - {{ ubuntu_appname }}
648+ - {{ bb_appname }}
649diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
650new file mode 100644
651index 0000000..9679d97
652--- /dev/null
653+++ b/tests/functional/conftest.py
654@@ -0,0 +1,68 @@
655+"""
656+Reusable pytest fixtures for functional testing.
657+
658+Environment variables
659+---------------------
660+
661+PYTEST_CLOUD_REGION, PYTEST_CLOUD_NAME: cloud name and region to use for juju model creation
662+
663+PYTEST_KEEP_MODEL: if set, the testing model won't be torn down at the end of the testing session
664+"""
665+
666+import asyncio
667+import os
668+import subprocess
669+import uuid
670+
671+from juju.controller import Controller
672+
673+from juju_tools import JujuTools
674+
675+import pytest
676+
677+
678+@pytest.fixture(scope='module')
679+def event_loop():
680+ """Override the default pytest event loop to allow for fixtures using a broader scope."""
681+ loop = asyncio.get_event_loop_policy().new_event_loop()
682+ asyncio.set_event_loop(loop)
683+ loop.set_debug(True)
684+ yield loop
685+ loop.close()
686+ asyncio.set_event_loop(None)
687+
688+
689+@pytest.fixture(scope='module')
690+async def controller():
691+ """Connect to the current controller."""
692+ _controller = Controller()
693+ await _controller.connect_current()
694+ yield _controller
695+ await _controller.disconnect()
696+
697+
698+@pytest.fixture(scope='module')
699+async def model(controller):
700+ """Create a temporary model to run the tests."""
701+ model_name = "functest-{}".format(str(uuid.uuid4())[-12:])
702+ _model = await controller.add_model(model_name,
703+ cloud_name=os.getenv('PYTEST_CLOUD_NAME'),
704+ region=os.getenv('PYTEST_CLOUD_REGION'),
705+ )
706+ # https://github.com/juju/python-libjuju/issues/267
707+ subprocess.check_call(['juju', 'models'])
708+ while model_name not in await controller.list_models():
709+ await asyncio.sleep(1)
710+ yield _model
711+ await _model.disconnect()
712+ if not os.getenv('PYTEST_KEEP_MODEL'):
713+ await controller.destroy_model(model_name)
714+ while model_name in await controller.list_models():
715+ await asyncio.sleep(1)
716+
717+
718+@pytest.fixture(scope='module')
719+async def jujutools(controller, model):
720+ """Load helpers to run commands on the units."""
721+ tools = JujuTools(controller, model)
722+ return tools
723diff --git a/tests/functional/juju_tools.py b/tests/functional/juju_tools.py
724new file mode 100644
725index 0000000..850c296
726--- /dev/null
727+++ b/tests/functional/juju_tools.py
728@@ -0,0 +1,71 @@
729+"""Juju helpers to run commands on the units."""
730+import base64
731+import pickle
732+
733+import juju
734+
735+
736+class JujuTools:
737+ """Load helpers to run commands on units."""
738+
739+ def __init__(self, controller, model):
740+ """Load initialized controller and model."""
741+ self.controller = controller
742+ self.model = model
743+
744+ async def run_command(self, cmd, target):
745+ """
746+ Run a command on a unit.
747+
748+ :param cmd: Command to be run
749+ :param unit: Unit object or unit name string
750+ """
751+ unit = (
752+ target
753+ if isinstance(target, juju.unit.Unit)
754+ else await self.get_unit(target)
755+ )
756+ action = await unit.run(cmd)
757+ return action.results
758+
759+ async def remote_object(self, imports, remote_cmd, target):
760+ """
761+ Run command on target machine and returns a python object of the result.
762+
763+ :param imports: Imports needed for the command to run
764+ :param remote_cmd: The python command to execute
765+ :param target: Unit object or unit name string
766+ """
767+ python3 = "python3 -c '{}'"
768+ python_cmd = ('import pickle;'
769+ 'import base64;'
770+ '{}'
771+ 'print(base64.b64encode(pickle.dumps({})), end="")'
772+ .format(imports, remote_cmd))
773+ cmd = python3.format(python_cmd)
774+ results = await self.run_command(cmd, target)
775+ return pickle.loads(base64.b64decode(bytes(results['Stdout'][2:-1], 'utf8')))
776+
777+ async def file_stat(self, path, target):
778+ """
779+ Run stat on a file.
780+
781+ :param path: File path
782+ :param target: Unit object or unit name string
783+ """
784+ imports = 'import os;'
785+ python_cmd = ('os.stat("{}")'
786+ .format(path))
787+ print("Calling remote cmd: " + python_cmd)
788+ return await self.remote_object(imports, python_cmd, target)
789+
790+ async def file_contents(self, path, target):
791+ """
792+ Return the contents of a file.
793+
794+ :param path: File path
795+ :param target: Unit object or unit name string
796+ """
797+ cmd = 'cat {}'.format(path)
798+ result = await self.run_command(cmd, target)
799+ return result['Stdout']
800diff --git a/tests/functional/requirements.txt b/tests/functional/requirements.txt
801new file mode 100644
802index 0000000..3d8a11b
803--- /dev/null
804+++ b/tests/functional/requirements.txt
805@@ -0,0 +1,7 @@
806+flake8
807+jinja2
808+juju
809+mock
810+pytest
811+pytest-asyncio
812+requests
813diff --git a/tests/functional/test_deploy.py b/tests/functional/test_deploy.py
814new file mode 100644
815index 0000000..863100c
816--- /dev/null
817+++ b/tests/functional/test_deploy.py
818@@ -0,0 +1,131 @@
819+"""Tests around Juju deployed charms."""
820+import asyncio
821+import os
822+import stat
823+import subprocess
824+
825+import jinja2
826+
827+import pytest
828+
829+# Treat all tests as coroutines
830+pytestmark = pytest.mark.asyncio
831+
832+CHARM_BUILD_DIR = os.getenv('CHARM_BUILD_DIR', '.').rstrip('/')
833+
834+series = ['bionic']
835+sources = [('local', os.path.join(CHARM_BUILD_DIR, 'prometheus-blackbox-exporter-peer')),
836+ # ('jujucharms', 'cs:...'),
837+ ]
838+
839+
840+def render(templates_dir, template_name, context):
841+ """Render a configuration file from a template."""
842+ templates = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
843+ template = templates.get_template(template_name)
844+ return template.render(context)
845+
846+# Uncomment for re-using the current model, useful for debugging functional tests
847+# @pytest.fixture(scope='module')
848+# async def model():
849+# from juju.model import Model
850+# model = Model()
851+# await model.connect_current()
852+# yield model
853+# await model.disconnect()
854+
855+
856+# Custom fixtures
857+@pytest.fixture(params=series)
858+def series(request):
859+ """Return series scope."""
860+ return request.param
861+
862+
863+@pytest.fixture(params=sources, ids=[s[0] for s in sources])
864+def source(request):
865+ """Return location of deployed charm (local disk or charm store)."""
866+ return request.param
867+
868+
869+@pytest.fixture
870+async def app(model, series, source):
871+ """Return Juju application object for the deployed series."""
872+ app_name = 'prometheus-blackbox-exporter-peer-{}-{}'.format(series, source[0])
873+ return await model._wait_for_new('application', app_name)
874+
875+
876+async def test_bbpeerexporter_deploy(model, series, source, request):
877+ """Start a deploy for each series.
878+
879+ subprocess is used because libjuju fails with JAAS
880+ https://github.com/juju/python-libjuju/issues/221
881+ """
882+ dir_path = os.path.dirname(os.path.realpath(__file__))
883+ bundle_path = os.path.join(CHARM_BUILD_DIR, 'bundle.yaml')
884+
885+ ubuntu_appname = "ubuntu-{}-{}".format(series, source[0])
886+ bb_appname = 'prometheus-blackbox-exporter-peer-{}-{}'.format(series, source[0])
887+
888+ context = {
889+ "ubuntu_appname": ubuntu_appname,
890+ "bb_appname": bb_appname,
891+ "series": series,
892+ "charm_path": os.path.join(CHARM_BUILD_DIR, "prometheus-blackbox-exporter-peer")
893+ }
894+ rendered = render(dir_path, "bundle.yaml.j2", context)
895+ with open(bundle_path, "w") as fd:
896+ fd.write(rendered)
897+
898+ if not os.path.exists(bundle_path):
899+ assert False
900+
901+ cmd = ["juju", "deploy", "-m", model.info.name, bundle_path]
902+ if request.node.get_closest_marker('xfail'):
903+ # If series is 'xfail' force install to allow testing against versions not in
904+ # metadata.yaml
905+ cmd.append('--force')
906+ subprocess.check_call(cmd)
907+ while True:
908+ try:
909+ model.applications[bb_appname]
910+ break
911+ except KeyError:
912+ await asyncio.sleep(5)
913+ assert True
914+
915+
916+# Tests
917+async def test_bbpeerexporter_status(model, app):
918+ """Verify status for all deployed series of the charm."""
919+ await model.block_until(lambda: app.status == 'active',
920+ timeout=900)
921+ unit = app.units[0]
922+ await model.block_until(lambda: unit.agent_status == 'idle',
923+ timeout=900)
924+
925+
926+# async def test_example_action(app):
927+# unit = app.units[0]
928+# action = await unit.run_action('example-action')
929+# action = await action.wait()
930+# assert action.status == 'completed'
931+
932+
933+async def test_run_command(app, jujutools):
934+ """Test simple juju-run command."""
935+ unit = app.units[0]
936+ cmd = 'hostname -i'
937+ results = await jujutools.run_command(cmd, unit)
938+ assert results['Code'] == '0'
939+ assert unit.public_address in results['Stdout']
940+
941+
942+async def test_file_stat(app, jujutools):
943+ """Verify a file exists in the deployed unit."""
944+ unit = app.units[0]
945+ path = '/var/lib/juju/agents/unit-{}/charm/metadata.yaml'.format(unit.entity_id.replace('/', '-'))
946+ fstat = await jujutools.file_stat(path, unit)
947+ assert stat.filemode(fstat.st_mode) == '-rw-r--r--'
948+ assert fstat.st_uid == 0
949+ assert fstat.st_gid == 0
950diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
951new file mode 100644
952index 0000000..eb2631b
953--- /dev/null
954+++ b/tests/unit/conftest.py
955@@ -0,0 +1,60 @@
956+"""Reusable pytest fixtures for functional testing."""
957+import unittest.mock as mock
958+
959+import pytest
960+
961+
962+# If layer options are used, add this to prometheusblackboxpeerexporter
963+# and import layer in lib_bb_peer_exporter
964+@pytest.fixture
965+def mock_layers(monkeypatch):
966+ """Mock imported modules and calls."""
967+ import sys
968+ sys.modules['charms.layer'] = mock.Mock()
969+ sys.modules['charms.reactive.helpers'] = mock.Mock()
970+ sys.modules['reactive'] = mock.Mock()
971+ # Mock any functions in layers that need to be mocked here
972+
973+
974+@pytest.fixture
975+def mock_hookenv_config(monkeypatch):
976+ """Mock hookenv.config()."""
977+ import yaml
978+
979+ def mock_config():
980+ cfg = {}
981+ yml = yaml.load(open('./config.yaml'))
982+
983+ # Load all defaults
984+ for key, value in yml['options'].items():
985+ cfg[key] = value['default']
986+
987+ # Manually add cfg from other layers
988+ return cfg
989+ # cfg['my-other-layer'] = 'mock'
990+
991+ monkeypatch.setattr('lib_bb_peer_exporter.hookenv.config', mock_config)
992+
993+
994+@pytest.fixture
995+def mock_remote_unit(monkeypatch):
996+ """Mock a remote unit name."""
997+ monkeypatch.setattr('lib_bb_peer_exporter.hookenv.remote_unit', lambda: 'unit-mock/0')
998+
999+
1000+@pytest.fixture
1001+def mock_charm_dir(monkeypatch):
1002+ """Mock CHARM_DIR Juju environment variable."""
1003+ monkeypatch.setattr('lib_bb_peer_exporter.hookenv.charm_dir', lambda: '/mock/charm/dir')
1004+
1005+
1006+@pytest.fixture
1007+def bbpeerexporter(tmpdir, mock_layers, mock_hookenv_config, mock_charm_dir, monkeypatch):
1008+ """Test the basic structure of the helper class."""
1009+ from lib_bb_peer_exporter import BBPeerExporterHelper
1010+ helper = BBPeerExporterHelper()
1011+
1012+ # Any other functions that load helper will get this version
1013+ monkeypatch.setattr('lib_bb_peer_exporter.BBPeerExporterHelper', lambda: helper)
1014+
1015+ return helper
1016diff --git a/tests/unit/requirements.txt b/tests/unit/requirements.txt
1017new file mode 100644
1018index 0000000..ec17338
1019--- /dev/null
1020+++ b/tests/unit/requirements.txt
1021@@ -0,0 +1,5 @@
1022+charmhelpers
1023+charms.reactive
1024+psutil
1025+pytest
1026+pytest-cov
1027diff --git a/tests/unit/samples/expected.multi b/tests/unit/samples/expected.multi
1028new file mode 100644
1029index 0000000..d1cb749
1030--- /dev/null
1031+++ b/tests/unit/samples/expected.multi
1032@@ -0,0 +1 @@
1033+{"172.16.138.0/25": {"iface": "bond3", "ip": "172.16.138.34", "net": "172.16.138.0/25", "mtu": "9000"}, "172.16.137.0/24": {"iface": "bond2", "ip": "172.16.137.79", "net": "172.16.137.0/24", "mtu": "9000"}, "172.16.148.0/23": {"iface": "bond1", "ip": "172.16.149.44", "net": "172.16.148.0/23", "mtu": "1500"}, "172.16.136.0/25": {"iface": "bond1.50", "ip": "172.16.136.43", "net": "172.16.136.0/25", "mtu": "1500"}}
1034\ No newline at end of file
1035diff --git a/tests/unit/samples/expected.single b/tests/unit/samples/expected.single
1036new file mode 100644
1037index 0000000..80023f6
1038--- /dev/null
1039+++ b/tests/unit/samples/expected.single
1040@@ -0,0 +1 @@
1041+{"10.205.6.0/24": {"iface": "eth0", "ip": "10.205.6.172", "net": "10.205.6.0/24", "mtu": "1500"}}
1042\ No newline at end of file
1043diff --git a/tests/unit/samples/if_addrs.multi b/tests/unit/samples/if_addrs.multi
1044new file mode 100644
1045index 0000000..e702820
1046Binary files /dev/null and b/tests/unit/samples/if_addrs.multi differ
1047diff --git a/tests/unit/samples/if_addrs.single b/tests/unit/samples/if_addrs.single
1048new file mode 100644
1049index 0000000..148b2d5
1050Binary files /dev/null and b/tests/unit/samples/if_addrs.single differ
1051diff --git a/tests/unit/samples/if_stats.multi b/tests/unit/samples/if_stats.multi
1052new file mode 100644
1053index 0000000..1b618b9
1054Binary files /dev/null and b/tests/unit/samples/if_stats.multi differ
1055diff --git a/tests/unit/samples/if_stats.single b/tests/unit/samples/if_stats.single
1056new file mode 100644
1057index 0000000..ca0eba5
1058Binary files /dev/null and b/tests/unit/samples/if_stats.single differ
1059diff --git a/tests/unit/test_lib_bb_peer_exporter.py b/tests/unit/test_lib_bb_peer_exporter.py
1060new file mode 100644
1061index 0000000..3d318e4
1062--- /dev/null
1063+++ b/tests/unit/test_lib_bb_peer_exporter.py
1064@@ -0,0 +1,12 @@
1065+"""Tests around lib_bb_peer_exporter module."""
1066+
1067+
1068+class TestLibBBExporter():
1069+ """Test suite for lib_bb_peer_exporter module."""
1070+
1071+ def test_bbpeerexporter(self, bbpeerexporter):
1072+ """See if the helper fixture works to load charm configs."""
1073+ assert isinstance(bbpeerexporter.charm_config, dict)
1074+ assert bbpeerexporter.modules
1075+ assert bbpeerexporter.scrape_interval == "60s"
1076+ assert bbpeerexporter.port_def == 9115
1077diff --git a/tests/unit/test_lib_network.py b/tests/unit/test_lib_network.py
1078new file mode 100644
1079index 0000000..40052cc
1080--- /dev/null
1081+++ b/tests/unit/test_lib_network.py
1082@@ -0,0 +1,40 @@
1083+"""Tests around lib_network module."""
1084+import os
1085+import pickle
1086+
1087+import lib_network
1088+
1089+import pytest
1090+
1091+DIRNAME = os.path.dirname(__file__)
1092+
1093+
1094+class TestLibNetwork():
1095+ """Test suite for lib_network module."""
1096+
1097+ @pytest.mark.parametrize('n_ifaces', ['single', 'multi'])
1098+ def test_get_unit_ipv4_networks(self, n_ifaces, monkeypatch):
1099+ """Retrieved psutil results return IPv4 interfaces details.
1100+
1101+ psutil.if_addrs() and psutil.if_stats() are mocked to simulate
1102+ - unit with a single IPv4 interface
1103+ - unit with multiple IPv4 interfaces and different MTU sizes"""
1104+ def load_obj(funcname, n_ifaces):
1105+ filename = os.path.join(
1106+ DIRNAME, 'samples', '{}.{}'.format(funcname, n_ifaces))
1107+ with open(filename, 'rb') as fd:
1108+ obj = pickle.load(fd)
1109+ return obj
1110+
1111+ def expected_result(n_ifaces):
1112+ filename = os.path.join(
1113+ DIRNAME, 'samples', 'expected.{}'.format(n_ifaces))
1114+ with open(filename, 'r') as fd:
1115+ expected = fd.read().strip()
1116+ return expected
1117+
1118+ if_addrs = load_obj('if_addrs', n_ifaces)
1119+ if_stats = load_obj('if_stats', n_ifaces)
1120+ monkeypatch.setattr('psutil.net_if_addrs', lambda: if_addrs)
1121+ monkeypatch.setattr('psutil.net_if_stats', lambda: if_stats)
1122+ assert lib_network.get_unit_ipv4_networks() == expected_result(n_ifaces)
1123diff --git a/tox.ini b/tox.ini
1124new file mode 100644
1125index 0000000..3715246
1126--- /dev/null
1127+++ b/tox.ini
1128@@ -0,0 +1,48 @@
1129+[tox]
1130+skipsdist=True
1131+envlist = unit, functional
1132+skip_missing_interpreters = True
1133+
1134+[testenv]
1135+basepython = python3
1136+setenv =
1137+ PYTHONPATH = .
1138+
1139+[testenv:unit]
1140+commands = pytest -v --ignore {toxinidir}/tests/functional \
1141+ --cov=lib \
1142+ --cov=reactive \
1143+ --cov=actions \
1144+ --cov-report=term \
1145+ --cov-report=annotate:report/annotated \
1146+ --cov-report=html:report/html
1147+deps = -r{toxinidir}/tests/unit/requirements.txt
1148+setenv = PYTHONPATH={toxinidir}/lib
1149+
1150+[testenv:functional]
1151+passenv =
1152+ HOME
1153+ CHARM_BUILD_DIR
1154+ PATH
1155+ PYTEST_KEEP_MODEL
1156+ PYTEST_CLOUD_NAME
1157+ PYTEST_CLOUD_REGION
1158+commands = pytest -v --ignore {toxinidir}/tests/unit
1159+deps = -r{toxinidir}/tests/functional/requirements.txt
1160+
1161+[testenv:lint]
1162+commands = flake8
1163+deps =
1164+ flake8
1165+ flake8-docstrings
1166+ flake8-import-order
1167+ pep8-naming
1168+ flake8-colors
1169+
1170+[flake8]
1171+exclude =
1172+ .git,
1173+ __pycache__,
1174+ .tox,
1175+max-line-length = 120
1176+max-complexity = 10
1177diff --git a/wheelhouse.txt b/wheelhouse.txt
1178new file mode 100644
1179index 0000000..a4d92cc
1180--- /dev/null
1181+++ b/wheelhouse.txt
1182@@ -0,0 +1 @@
1183+psutil

Subscribers

People subscribed via source and target branches

to all changes: