Merge ~gabrielcocenza/juju-lint:models-no-apps into juju-lint:master

Proposed by Gabriel Cocenza
Status: Merged
Approved by: Martin Kalcok
Approved revision: 372aa6bdabb924d20515a2d5c70fdecebe0f535a
Merged at revision: a04794c69be9e3c34979fc4a1f1a89f0ce175f1e
Proposed branch: ~gabrielcocenza/juju-lint:models-no-apps
Merge into: juju-lint:master
Prerequisite: ~gabrielcocenza/juju-lint:update-snap
Diff against target: 348 lines (+253/-29)
3 files modified
jujulint/cloud.py (+39/-29)
tests/conftest.py (+85/-0)
tests/test_cloud.py (+129/-0)
Reviewer Review Type Date Requested Status
Martin Kalcok (community) Approve
Eric Chen Approve
Review via email: mp+424910@code.launchpad.net

Commit message

update get_juju_bundle method

- models without apps don't crash juju-lint.
- added saas into cloud_state for future cmr support.
- added unit tests and fixtures for cloud module.

To post a comment you must log in.
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
Eric Chen (eric-chen) wrote :

+1

review: Approve
Revision history for this message
Martin Kalcok (martin-kalcok) wrote :

+1

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

Change successfully merged at revision a04794c69be9e3c34979fc4a1f1a89f0ce175f1e

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/jujulint/cloud.py b/jujulint/cloud.py
2index 3382e3b..6d0ec44 100644
3--- a/jujulint/cloud.py
4+++ b/jujulint/cloud.py
5@@ -34,7 +34,7 @@ Todo:
6
7 """
8 import socket
9-from subprocess import check_output
10+from subprocess import CalledProcessError, check_output
11
12 import yaml
13 from fabric2 import Config, Connection
14@@ -94,6 +94,7 @@ class Cloud:
15 self.logger.debug("Running local command: {}".format(command))
16 args = command.split(" ")
17 return check_output(args)
18+
19 elif self.access_method == "ssh":
20 if self.sudo_user:
21 self.logger.debug(
22@@ -272,9 +273,22 @@ class Cloud:
23
24 def get_juju_bundle(self, controller, model):
25 """Get an export of the juju bundle for the provided model."""
26- bundle_data = self.run_command(
27- "juju export-bundle -m {}:{}".format(controller, model)
28- )
29+ try:
30+ bundle_data = self.run_command(
31+ "juju export-bundle -m {}:{}".format(controller, model)
32+ )
33+ except CalledProcessError as e:
34+ self.logger.error(e)
35+ self.logger.warn(
36+ (
37+ "An error happened to get the bundle on {}:{}. "
38+ "If the model doesn't have apps, disconsider this message.".format(
39+ controller, model
40+ )
41+ )
42+ )
43+ return
44+
45 bundles = self.parse_yaml(bundle_data)
46 self.logger.info(
47 "[{}] Processing Juju bundle export for model {} on controller {}".format(
48@@ -286,37 +300,33 @@ class Cloud:
49 model, controller, bundles
50 )
51 )
52- if len(bundles) > 0:
53- combined = {}
54- for bundle in bundles:
55- combined.update(bundle)
56- if "applications" in combined:
57- for application in combined["applications"].keys():
58+ # NOTE(gabrielcocenza) export-bundle can have an overlay when there is crm.
59+ for bundle in bundles:
60+ if "applications" in bundle:
61+ for application in bundle["applications"].keys():
62 self.logger.debug(
63 "Parsing configuration for application {} in model {}: {}".format(
64- application, model, combined
65+ application, model, bundle
66 )
67 )
68- application_config = combined["applications"][application]
69- if (
70+ application_config = bundle["applications"][application]
71+ self.cloud_state[controller]["models"][model].setdefault(
72+ "applications", {}
73+ )
74+
75+ self.cloud_state[controller]["models"][model][
76 "applications"
77- not in self.cloud_state[controller]["models"][model]
78- ):
79+ ].setdefault(application, {}).update(application_config)
80+ if "saas" in bundle:
81+ for app in bundle.get("saas"):
82+ # offer side doesn't show the url of the app
83+ if bundle["saas"][app].get("url"):
84+ self.cloud_state[controller]["models"][model].setdefault(
85+ "saas", {}
86+ ).update(bundle["saas"])
87 self.cloud_state[controller]["models"][model][
88- "applications"
89- ] = {}
90- if (
91- application
92- not in self.cloud_state[controller]["models"][model][
93- "applications"
94- ].keys()
95- ):
96- self.cloud_state[controller]["models"][model]["applications"][
97- application
98- ] = {}
99- self.cloud_state[controller]["models"][model]["applications"][
100- application
101- ].update(application_config)
102+ "saas"
103+ ].setdefault(app, {}).update(bundle["saas"][app])
104
105 def get_juju_state(self):
106 """Update our view of Juju-managed application state."""
107diff --git a/tests/conftest.py b/tests/conftest.py
108index 55ac1ac..b43a273 100644
109--- a/tests/conftest.py
110+++ b/tests/conftest.py
111@@ -10,6 +10,7 @@
112
113 import os
114 import sys
115+from unittest.mock import MagicMock
116
117 import mock
118 import pytest
119@@ -76,6 +77,24 @@ def linter(parser):
120
121
122 @pytest.fixture
123+def cloud():
124+ """Provide a Cloud instance to test."""
125+ from jujulint.cloud import Cloud
126+
127+ rules = {
128+ "known charms": ["nrpe", "ubuntu", "nagios"],
129+ "operations mandatory": ["nagios"],
130+ }
131+ cloud = Cloud(name="test_cloud", lint_rules=rules)
132+ # set initial cloud state
133+ cloud.cloud_state = {
134+ "my_controller": {"models": {"my_model_1": {}, "my_model_2": {}}}
135+ }
136+ cloud.logger = MagicMock()
137+ return cloud
138+
139+
140+@pytest.fixture
141 def juju_status():
142 """Provide a base juju status for testing."""
143 return {
144@@ -125,3 +144,69 @@ def juju_status():
145 },
146 },
147 }
148+
149+
150+@pytest.fixture
151+def juju_export_bundle():
152+ """Simulate a cloud with one controller and two bundles.
153+
154+ my_model_1 nrpe offers the monitors endpoint
155+ my_model_2 nagios consumes the monitors endpoint from my_model_1
156+ """
157+ return {
158+ "my_model_1": [
159+ {
160+ "series": "focal",
161+ "saas": {"remote-2290e64ea1ac41858eb06a69b6a9d8cc": {}},
162+ "applications": {
163+ "nrpe": {"charm": "nrpe", "channel": "stable", "revision": 86},
164+ "ubuntu": {
165+ "charm": "ubuntu",
166+ "channel": "stable",
167+ "revision": 19,
168+ "num_units": 1,
169+ "to": ["0"],
170+ "constraints": "arch=amd64",
171+ },
172+ },
173+ "machines": {"0": {"constraints": "arch=amd64"}},
174+ "relations": [
175+ ["nrpe:general-info", "ubuntu:juju-info"],
176+ [
177+ "nrpe:monitors",
178+ "remote-2290e64ea1ac41858eb06a69b6a9d8cc:monitors",
179+ ],
180+ ],
181+ },
182+ {
183+ "applications": {
184+ "nrpe": {
185+ "offers": {
186+ "nrpe": {
187+ "endpoints": ["monitors"],
188+ "acl": {"admin": "admin"},
189+ }
190+ }
191+ }
192+ }
193+ },
194+ ],
195+ "my_model_2": [
196+ {
197+ "series": "bionic",
198+ "saas": {"nrpe": {"url": "my_controller:admin/my_model_1.nrpe"}},
199+ "applications": {
200+ "nagios": {
201+ "charm": "nagios",
202+ "channel": "stable",
203+ "revision": 49,
204+ "num_units": 1,
205+ "to": ["0"],
206+ "constraints": "arch=amd64",
207+ }
208+ },
209+ "machines": {"0": {"constraints": "arch=amd64"}},
210+ "relations": [["nagios:monitors", "nrpe:monitors"]],
211+ }
212+ ],
213+ }
214diff --git a/tests/test_cloud.py b/tests/test_cloud.py
215new file mode 100644
216index 0000000..885f319
217--- /dev/null
218+++ b/tests/test_cloud.py
219@@ -0,0 +1,129 @@
220+#!/usr/bin/python3
221+"""Tests for Cloud."""
222+from subprocess import CalledProcessError
223+from unittest.mock import call, patch
224+
225+
226+class TestCloud:
227+ """Test the main Cloud class."""
228+
229+ @patch("jujulint.cloud.check_output")
230+ def test_get_bundle_no_apps(self, mock_check_out, cloud):
231+ """Models with no apps raises CalledProcessError to export bundle."""
232+ cmd = ["juju", "export-bundle", "-m", "my_controller:controller"]
233+ e = CalledProcessError(1, cmd)
234+ mock_check_out.side_effect = e
235+ cloud.get_juju_bundle("my_controller", "controller")
236+ expected_error_msg = call.error(e)
237+
238+ expected_warn_msg = call.warn(
239+ (
240+ "An error happened to get the bundle on my_controller:controller. "
241+ "If the model doesn't have apps, disconsider this message."
242+ )
243+ )
244+ assert expected_error_msg in cloud.logger.method_calls
245+ assert expected_warn_msg in cloud.logger.method_calls
246+
247+ @patch("jujulint.cloud.Cloud.parse_yaml")
248+ @patch("jujulint.cloud.Cloud.run_command")
249+ def test_get_bundle_offer_side(
250+ self, mock_run, mock_parse, cloud, juju_export_bundle
251+ ):
252+ """Test the bundle generated in the offer side."""
253+ # simulate cloud_state with info that came from "get_juju_status"
254+ cloud.cloud_state = {
255+ "my_controller": {
256+ "models": {
257+ "my_model_1": {
258+ "applications": {
259+ "nrpe": {
260+ "charm-origin": "charmhub",
261+ "os": "ubuntu",
262+ "endpoint-bindings": {"": "alpha", "monitors": "alpha"},
263+ }
264+ }
265+ },
266+ "my_model_2": {},
267+ }
268+ }
269+ }
270+ mock_parse.return_value = juju_export_bundle["my_model_1"]
271+ # "offers" field exists inside nrpe because of the overlay bundle.
272+ # saas field doesn't exist in the offer side because there is no url.
273+ # don't overwrite information that came from "get_juju_status".
274+ expected_cloud_state = {
275+ "my_controller": {
276+ "models": {
277+ "my_model_1": {
278+ "applications": {
279+ "nrpe": {
280+ "charm": "nrpe",
281+ "charm-origin": "charmhub",
282+ "os": "ubuntu",
283+ "endpoint-bindings": {"": "alpha", "monitors": "alpha"},
284+ "channel": "stable",
285+ "revision": 86,
286+ "offers": {
287+ "nrpe": {
288+ "endpoints": ["monitors"],
289+ "acl": {"admin": "admin"},
290+ }
291+ },
292+ },
293+ "ubuntu": {
294+ "charm": "ubuntu",
295+ "channel": "stable",
296+ "revision": 19,
297+ "num_units": 1,
298+ "to": ["0"],
299+ "constraints": "arch=amd64",
300+ },
301+ }
302+ },
303+ "my_model_2": {},
304+ }
305+ }
306+ }
307+ cloud.get_juju_bundle("my_controller", "my_model_1")
308+ assert mock_run.called_once_with(
309+ "juju export-bundle -m my_controller:my_model_1"
310+ )
311+ assert cloud.cloud_state == expected_cloud_state
312+
313+ @patch("jujulint.cloud.Cloud.parse_yaml")
314+ @patch("jujulint.cloud.Cloud.run_command")
315+ def test_get_bundle_consumer_side(
316+ self, mock_run, mock_parse, cloud, juju_export_bundle
317+ ):
318+ """Test the bundle generated in the consumer side."""
319+ mock_parse.return_value = juju_export_bundle["my_model_2"]
320+ # "offers" field won't exist in the consumer side
321+ # saas field exists because the consumer side shows url
322+ expected_cloud_state = {
323+ "my_controller": {
324+ "models": {
325+ "my_model_1": {},
326+ "my_model_2": {
327+ "applications": {
328+ "nagios": {
329+ "charm": "nagios",
330+ "channel": "stable",
331+ "revision": 49,
332+ "num_units": 1,
333+ "to": ["0"],
334+ "constraints": "arch=amd64",
335+ }
336+ },
337+ "saas": {
338+ "nrpe": {"url": "my_controller:admin/my_model_1.nrpe"}
339+ },
340+ },
341+ }
342+ }
343+ }
344+ cloud.get_juju_bundle("my_controller", "my_model_2")
345+ assert mock_run.called_once_with(
346+ "juju export-bundle -m my_controller:my_model_2"
347+ )
348+ assert cloud.cloud_state == expected_cloud_state

Subscribers

People subscribed via source and target branches