Merge ~mertkirpici/juju-lint:lp/1987951 into juju-lint:master

Proposed by Mert Kirpici
Status: Merged
Approved by: Eric Chen
Approved revision: ef16249f7f6c281351f5cc8d9472c881d95133a5
Merged at revision: d89eef7b64063e543bffb1e292152a9118e1dfe9
Proposed branch: ~mertkirpici/juju-lint:lp/1987951
Merge into: juju-lint:master
Diff against target: 306 lines (+158/-14)
7 files modified
jujulint/cli.py (+24/-5)
jujulint/config.py (+13/-7)
jujulint/util.py (+14/-0)
tests/functional/conftest.py (+21/-0)
tests/functional/test_jujulint.py (+47/-0)
tests/unit/conftest.py (+1/-0)
tests/unit/test_cli.py (+38/-2)
Reviewer Review Type Date Requested Status
Gabriel Cocenza Approve
Martin Kalcok (community) Approve
Eric Chen Approve
BootStack Reviewers Pending
Review via email: mp+429025@code.launchpad.net

Commit message

Close LP #1987951

Description of the change

config: make --dump-state boolean

The command line argument --dump-state is in fact used as a boolean flag
however it expects a dummy argument, causing confusion. With this change
it only using it will be enough. i.e.:

  $ juju-lint --dump-state -d outdir -c ...

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 :

LGTM

review: Approve
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Lets wait until the MP[0] for the functional tests are merged for this. I want to add a functional test for the --dump-state as well.

[0] https://code.launchpad.net/~mertkirpici/juju-lint/+git/juju-lint/+merge/428809

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

Thanks for filling the bug and sending this patch.

Looking at the source code I think we should get rid off this dump-state flag. IMO it just makes harder to use the CLI. If the user passes the output dir, why should we force use another flag to dump it?

Moreover, I see three "big" problems that we should think about it:

1 - help of "output-dir" in the cli says "The folder to use when saving gathered cloud data and lint reports." I've checked and we are not writing lint reports. The dump contains the content of the following juju comands:
 - juju controllers
 - juju status
 - juju export-bundle

2 - the command juju controllers shows sensible information (e.g: ca-cert of the controller). I see this as a security issue and I think it's not a good standard behavior.

3 - Finally, we are not dealing with wrong path from the user. Passing an invalid path raises FileNotFoundError.

review: Needs Fixing
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Rebased the branch and pushed an update to partially address Gabriel's comments.
A small summary of changes:
- I did get rid of the `--output-dir` flag rather than the `--dump-state` since it felt more intuitive for me to use it like "dump the state to DIR", rather than "output to DIR" which begs the question "What output?"

- Now we are checking the dump directory for permissions and existence before running any sort of linting.

- New functests for this feature.

I am thinking of filing bugs and addressing the issues #1(missing lint results in dumps) and #2(sensitive info in dumps) in separate MPs since they are quite serious bugs within themselves.

Last but not least, here is the functest run after this change:
https://pastebin.ubuntu.com/p/bgsfqGwVVh/

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

The patch LGTM, but unit tests are failing because of the changes and because now we need 100% line coverage.

Regarding the bugs, please fill the first one. The second one after talking with Martin, it's not that bad because it's the public certificate of the controller.

Thanks!

review: Needs Fixing
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

Hey again, so first of all I did go ahead and file LP#1989673 for the first item you mentioned.
https://bugs.launchpad.net/juju-lint/+bug/1989673

After some thought I decided to deprecate the `--dump-state` rather than directly removing it, so this newly pushed update is mainly dealing with that. It:

- introduces a new argparse.Action subclass for deprecation
- marks `--dump-state` as nargs="*" therefore no matter with how many arguments it is called, it will be valid(and no-op, just log a warning message)
- fixes breaking unittests and adds a test to ensure %100 coverage
- re-renames some tests and function names for consistency's sake

Here is the `make test` output:
https://pastebin.ubuntu.com/p/rqd5tcDVS2/

I do have one request though. If you think this is good to go, could you let me know before merging so that I can squash the commits and add explanation for the changes in the commit message to clear up the intention behind this change?

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

Just one note and I think this is ready for merge. Please include the warning about sensitive data being dumped, that was previously shown in the "--dump-state" help message, in the "--output-dir".

review: Approve
Revision history for this message
Eric Chen (eric-chen) wrote :

Please Gabriel Angelo Sgarbi Cocenza confirm this. And please mert provides the log of lint/unit/func if jenking bot is not ready.

Any MR should be

1) 0 "Need Fixing"
2) 2+ approval
3) approval of jenkins bot or provide log of lint/unit/func manually

Revision history for this message
Gabriel Cocenza (gabrielcocenza) wrote :

LGTM, but before merging please see my comments and please provide the logs of the tests.

review: Approve
Revision history for this message
Mert Kirpici (mertkirpici) wrote :

updated one last time. changes:
- squashed all commits, re-written the commit message, rebased onto current master
- added a forgotten @pytest.fixture to fix the unit tests
- I really liked Gabriel's idea to parametrize the bad output directory tests. so did that.
- Following review comments from Martin and Gabriel, added the warning about sensitive info into the help string and removed that part that mentions lint reports being dumped. Lint reports are not dumped currently, I'd already filed a LP bug about this.

`make test`:
https://pastebin.ubuntu.com/p/tqggRBjmwb/

all green, ready to land this change.

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

Change successfully merged at revision d89eef7b64063e543bffb1e292152a9118e1dfe9

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/jujulint/cli.py b/jujulint/cli.py
2index 2ea188a..ac85b2b 100755
3--- a/jujulint/cli.py
4+++ b/jujulint/cli.py
5@@ -17,9 +17,11 @@
6 # You should have received a copy of the GNU General Public License
7 # along with this program. If not, see <http://www.gnu.org/licenses/>.
8 """Main entrypoint for the juju-lint CLI."""
9+import errno
10 import logging
11 import os.path
12 import sys
13+import tempfile
14
15 import pkg_resources
16 import yaml
17@@ -127,6 +129,7 @@ class Cli:
18
19 def audit_all(self):
20 """Iterate over clouds and run audit."""
21+ self._check_output_folder()
22 self.logger.debug("Starting audit")
23 for cloud_name in self.config["clouds"].get():
24 self.audit(cloud_name)
25@@ -178,11 +181,27 @@ class Cli:
26
27 def write_yaml(self, data, file_name):
28 """Write collected information to YAML."""
29- if "dump" in self.config["output"]:
30- if self.config["output"]["dump"]:
31- folder_name = self.config["output"]["folder"].get()
32- file_handle = open("{}/{}".format(folder_name, file_name), "w")
33- yaml.dump(data, file_handle)
34+ folder_name = self.config["output"]["folder"].get()
35+ if folder_name:
36+ file_handle = open("{}/{}".format(folder_name, file_name), "w")
37+ yaml.dump(data, file_handle)
38+
39+ def _check_output_folder(self):
40+ """Check the output folder for permission and existence."""
41+ outdir = self.config["output"]["folder"].get()
42+ if outdir:
43+ try:
44+ with tempfile.TemporaryFile(dir=outdir):
45+ pass # pragma: no cover
46+ except FileNotFoundError as err:
47+ err.filename = outdir
48+ Logger.fubar(msg=str(err), exit_code=errno.ENOENT)
49+ except PermissionError as err:
50+ err.filename = outdir
51+ Logger.fubar(msg=str(err), exit_code=errno.EACCES)
52+ except Exception as err:
53+ err.filename = outdir
54+ Logger.fubar(msg=str(err))
55
56
57 def main():
58diff --git a/jujulint/config.py b/jujulint/config.py
59index a4822ee..45c5618 100644
60--- a/jujulint/config.py
61+++ b/jujulint/config.py
62@@ -22,6 +22,8 @@ from argparse import ArgumentParser
63
64 from confuse import Configuration
65
66+from jujulint.util import DeprecateAction
67+
68
69 class Config(Configuration):
70 """Helper class for holding parsed config, extending confuse's BaseConfiguraion class."""
71@@ -44,18 +46,22 @@ class Config(Configuration):
72 "-d",
73 "--output-dir",
74 type=str,
75- default="output",
76- nargs="?",
77- help="The folder to use when saving gathered cloud data and lint reports.",
78+ default="",
79+ metavar="DIR",
80+ help=(
81+ "Dump gathered cloud state data into %(metavar)s. "
82+ "Note that %(metavar)s must exist and be writable by the user. "
83+ "Use with caution, as dumps will contain sensitve data. "
84+ "This feature is disabled by default."
85+ ),
86 dest="output.folder",
87 )
88 self.parser.add_argument(
89 "--dump-state",
90 type=str,
91- help=(
92- "Optionally, dump cloud state as YAML into --output-dir."
93- "Use with caution, as dumps will contain sensitve data."
94- ),
95+ nargs="*",
96+ action=DeprecateAction,
97+ help="DEPRECATED. See --output-dir for the current behavior",
98 dest="output.dump",
99 )
100 self.parser.add_argument(
101diff --git a/jujulint/util.py b/jujulint/util.py
102index 283fa4b..3b24419 100644
103--- a/jujulint/util.py
104+++ b/jujulint/util.py
105@@ -18,8 +18,11 @@
106 # along with this program. If not, see <http://www.gnu.org/licenses/>.
107 """Utility library for all helpful functions this project uses."""
108
109+import argparse
110 import re
111
112+from jujulint.logging import Logger
113+
114
115 class InvalidCharmNameError(Exception):
116 """Represents an invalid charm name being processed."""
117@@ -82,3 +85,14 @@ def extract_charm_name(charm):
118 if not match:
119 raise InvalidCharmNameError("charm name '{}' is invalid".format(charm))
120 return match.group(1)
121+
122+
123+class DeprecateAction(argparse.Action): # pragma: no cover
124+ """Custom deprecation action to be used with ArgumentParser."""
125+
126+ def __call__(self, parser, namespace, values, option_string=None):
127+ """Print a deprecation warning and remove the attribute from the namespace."""
128+ Logger().warn(
129+ "The argument {} is deprecated and will be ignored. ".format(option_string)
130+ )
131+ delattr(namespace, self.dest)
132diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
133index d26b318..7c215c1 100644
134--- a/tests/functional/conftest.py
135+++ b/tests/functional/conftest.py
136@@ -2,7 +2,9 @@
137 import logging
138 import os
139 import shutil
140+from pathlib import Path
141 from subprocess import check_call, check_output
142+from tempfile import TemporaryDirectory
143 from textwrap import dedent
144
145 import pytest
146@@ -127,3 +129,22 @@ def local_cloud():
147 if backup:
148 logging.info("Restoring backup")
149 shutil.move(local_config_dir + ".bak", local_config_dir)
150+
151+
152+@pytest.fixture
153+def non_existent_directory():
154+ """Return a non-existent directory."""
155+ dir = Path("/a/path/that/does/not/exist")
156+ assert not dir.exists()
157+ return dir
158+
159+
160+@pytest.fixture
161+def non_writable_directory():
162+ """Return a non-writable directory."""
163+ dir = TemporaryDirectory()
164+ os.chmod(dir.name, mode=0o555)
165+
166+ yield dir.name
167+
168+ dir.cleanup()
169diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py
170index e15e9b6..9d908f1 100644
171--- a/tests/functional/test_jujulint.py
172+++ b/tests/functional/test_jujulint.py
173@@ -47,3 +47,50 @@ async def test_audit_local_cloud(ops_test, local_cloud, rules_file):
174 f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr
175 )
176 assert returncode == 0
177+
178+
179+@pytest.mark.cloud
180+async def test_output_folder(ops_test, local_cloud, rules_file, tmp_path):
181+ """Test juju-lint state output to folder."""
182+ all_data_yaml = tmp_path / "all-data.yaml"
183+ cloudstate_yaml = tmp_path / f"{local_cloud}-state.yaml"
184+ assert not all_data_yaml.exists() and not cloudstate_yaml.exists()
185+
186+ await ops_test.model.wait_for_idle()
187+ returncode, _, stderr = await ops_test.run(
188+ *f"juju-lint -d {tmp_path} -c {rules_file}".split()
189+ )
190+
191+ assert (
192+ f"[{local_cloud}] Linting model information for {socket.getfqdn()}, "
193+ f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr
194+ )
195+ assert returncode == 0
196+ assert all_data_yaml.exists() and cloudstate_yaml.exists()
197+
198+
199+@pytest.mark.parametrize(
200+ "bad_output_folder, expected_error",
201+ [
202+ ("non_existent_directory", "No such file or directory"),
203+ ("non_writable_directory", "Permission denied"),
204+ ],
205+)
206+@pytest.mark.cloud
207+async def test_bad_output_folder_error(
208+ ops_test, local_cloud, rules_file, bad_output_folder, expected_error, request
209+):
210+ """Test juju-lint fails gracefully for bad output folder values."""
211+ output_folder = request.getfixturevalue(bad_output_folder)
212+
213+ await ops_test.model.wait_for_idle()
214+ returncode, _, stderr = await ops_test.run(
215+ *f"juju-lint -d {output_folder} -c {rules_file}".split()
216+ )
217+ assert returncode != 0
218+ assert (
219+ f"[{local_cloud}] Linting model information for {socket.getfqdn()}, "
220+ f"controller {ops_test.controller_name}, model {ops_test.model_name}"
221+ not in stderr
222+ )
223+ assert expected_error in stderr
224diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
225index 4d3d3ee..00500ea 100644
226--- a/tests/unit/conftest.py
227+++ b/tests/unit/conftest.py
228@@ -239,6 +239,7 @@ def rules_files():
229 ]
230
231
232+@pytest.fixture
233 def juju_status_relation():
234 """Representation of juju status input to test relations checks."""
235 return {
236diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
237index ed63f67..81d8934 100644
238--- a/tests/unit/test_cli.py
239+++ b/tests/unit/test_cli.py
240@@ -1,5 +1,6 @@
241 #!/usr/bin/python3
242 """Test the CLI."""
243+import errno
244 from logging import WARN
245 from unittest.mock import MagicMock, call
246
247@@ -254,7 +255,11 @@ def test_cli_audit_all(cli_instance, mocker):
248 clouds = MagicMock()
249 clouds.get.return_value = clouds_value
250
251- config_data = {"clouds": clouds}
252+ folder_value = ""
253+ folder = MagicMock()
254+ folder.get.return_value = folder_value
255+
256+ config_data = {"clouds": clouds, "output": {"folder": folder}}
257 config = MagicMock()
258 config.__getitem__.side_effect = config_data.__getitem__
259
260@@ -336,7 +341,7 @@ def test_cli_write_yaml(cli_instance, mocker):
261 opened_file = MagicMock()
262 mock_open = mocker.patch("builtins.open", return_value=opened_file)
263
264- config = {"output": {"dump": True, "folder": output_folder}}
265+ config = {"output": {"folder": output_folder}}
266
267 cli_instance.config = config
268 cli_instance.write_yaml(data, file_name)
269@@ -347,6 +352,37 @@ def test_cli_write_yaml(cli_instance, mocker):
270 yaml_mock.dump.assert_called_once_with(data, opened_file)
271
272
273+def test_check_output_folder(cli_instance, mocker):
274+ """Test _check_output_folder() method from Cli class."""
275+ folder_value = "/a/non/empty/path/string"
276+ folder = MagicMock()
277+ folder.get.return_value = folder_value
278+
279+ config = {"output": {"folder": folder}}
280+ cli_instance.config = config
281+
282+ mock_temporary_file = mocker.patch("tempfile.TemporaryFile")
283+ mock_sys_exit = mocker.patch("sys.exit")
284+
285+ mock_temporary_file.return_value.__enter__.side_effect = FileNotFoundError()
286+ cli_instance._check_output_folder()
287+ mock_sys_exit.assert_called_once_with(errno.ENOENT)
288+
289+ mock_temporary_file.reset_mock(return_value=True)
290+ mock_sys_exit.reset_mock()
291+
292+ mock_temporary_file.return_value.__enter__.side_effect = PermissionError()
293+ cli_instance._check_output_folder()
294+ mock_sys_exit.assert_called_once_with(errno.EACCES)
295+
296+ mock_temporary_file.reset_mock(return_value=True)
297+ mock_sys_exit.reset_mock()
298+
299+ mock_temporary_file.return_value.__enter__.side_effect = Exception()
300+ cli_instance._check_output_folder()
301+ mock_sys_exit.assert_called_once_with(1)
302+
303+
304 @pytest.mark.parametrize("audit_type", ["file", "all", None])
305 def test_main(cli_instance, audit_type, mocker):
306 """Test main entrypoint of jujulint."""

Subscribers

People subscribed via source and target branches