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
diff --git a/jujulint/cli.py b/jujulint/cli.py
index 2ea188a..ac85b2b 100755
--- a/jujulint/cli.py
+++ b/jujulint/cli.py
@@ -17,9 +17,11 @@
17# You should have received a copy of the GNU General Public License17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Main entrypoint for the juju-lint CLI."""19"""Main entrypoint for the juju-lint CLI."""
20import errno
20import logging21import logging
21import os.path22import os.path
22import sys23import sys
24import tempfile
2325
24import pkg_resources26import pkg_resources
25import yaml27import yaml
@@ -127,6 +129,7 @@ class Cli:
127129
128 def audit_all(self):130 def audit_all(self):
129 """Iterate over clouds and run audit."""131 """Iterate over clouds and run audit."""
132 self._check_output_folder()
130 self.logger.debug("Starting audit")133 self.logger.debug("Starting audit")
131 for cloud_name in self.config["clouds"].get():134 for cloud_name in self.config["clouds"].get():
132 self.audit(cloud_name)135 self.audit(cloud_name)
@@ -178,11 +181,27 @@ class Cli:
178181
179 def write_yaml(self, data, file_name):182 def write_yaml(self, data, file_name):
180 """Write collected information to YAML."""183 """Write collected information to YAML."""
181 if "dump" in self.config["output"]:184 folder_name = self.config["output"]["folder"].get()
182 if self.config["output"]["dump"]:185 if folder_name:
183 folder_name = self.config["output"]["folder"].get()186 file_handle = open("{}/{}".format(folder_name, file_name), "w")
184 file_handle = open("{}/{}".format(folder_name, file_name), "w")187 yaml.dump(data, file_handle)
185 yaml.dump(data, file_handle)188
189 def _check_output_folder(self):
190 """Check the output folder for permission and existence."""
191 outdir = self.config["output"]["folder"].get()
192 if outdir:
193 try:
194 with tempfile.TemporaryFile(dir=outdir):
195 pass # pragma: no cover
196 except FileNotFoundError as err:
197 err.filename = outdir
198 Logger.fubar(msg=str(err), exit_code=errno.ENOENT)
199 except PermissionError as err:
200 err.filename = outdir
201 Logger.fubar(msg=str(err), exit_code=errno.EACCES)
202 except Exception as err:
203 err.filename = outdir
204 Logger.fubar(msg=str(err))
186205
187206
188def main():207def main():
diff --git a/jujulint/config.py b/jujulint/config.py
index a4822ee..45c5618 100644
--- a/jujulint/config.py
+++ b/jujulint/config.py
@@ -22,6 +22,8 @@ from argparse import ArgumentParser
2222
23from confuse import Configuration23from confuse import Configuration
2424
25from jujulint.util import DeprecateAction
26
2527
26class Config(Configuration):28class Config(Configuration):
27 """Helper class for holding parsed config, extending confuse's BaseConfiguraion class."""29 """Helper class for holding parsed config, extending confuse's BaseConfiguraion class."""
@@ -44,18 +46,22 @@ class Config(Configuration):
44 "-d",46 "-d",
45 "--output-dir",47 "--output-dir",
46 type=str,48 type=str,
47 default="output",49 default="",
48 nargs="?",50 metavar="DIR",
49 help="The folder to use when saving gathered cloud data and lint reports.",51 help=(
52 "Dump gathered cloud state data into %(metavar)s. "
53 "Note that %(metavar)s must exist and be writable by the user. "
54 "Use with caution, as dumps will contain sensitve data. "
55 "This feature is disabled by default."
56 ),
50 dest="output.folder",57 dest="output.folder",
51 )58 )
52 self.parser.add_argument(59 self.parser.add_argument(
53 "--dump-state",60 "--dump-state",
54 type=str,61 type=str,
55 help=(62 nargs="*",
56 "Optionally, dump cloud state as YAML into --output-dir."63 action=DeprecateAction,
57 "Use with caution, as dumps will contain sensitve data."64 help="DEPRECATED. See --output-dir for the current behavior",
58 ),
59 dest="output.dump",65 dest="output.dump",
60 )66 )
61 self.parser.add_argument(67 self.parser.add_argument(
diff --git a/jujulint/util.py b/jujulint/util.py
index 283fa4b..3b24419 100644
--- a/jujulint/util.py
+++ b/jujulint/util.py
@@ -18,8 +18,11 @@
18# along with this program. If not, see <http://www.gnu.org/licenses/>.18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19"""Utility library for all helpful functions this project uses."""19"""Utility library for all helpful functions this project uses."""
2020
21import argparse
21import re22import re
2223
24from jujulint.logging import Logger
25
2326
24class InvalidCharmNameError(Exception):27class InvalidCharmNameError(Exception):
25 """Represents an invalid charm name being processed."""28 """Represents an invalid charm name being processed."""
@@ -82,3 +85,14 @@ def extract_charm_name(charm):
82 if not match:85 if not match:
83 raise InvalidCharmNameError("charm name '{}' is invalid".format(charm))86 raise InvalidCharmNameError("charm name '{}' is invalid".format(charm))
84 return match.group(1)87 return match.group(1)
88
89
90class DeprecateAction(argparse.Action): # pragma: no cover
91 """Custom deprecation action to be used with ArgumentParser."""
92
93 def __call__(self, parser, namespace, values, option_string=None):
94 """Print a deprecation warning and remove the attribute from the namespace."""
95 Logger().warn(
96 "The argument {} is deprecated and will be ignored. ".format(option_string)
97 )
98 delattr(namespace, self.dest)
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index d26b318..7c215c1 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -2,7 +2,9 @@
2import logging2import logging
3import os3import os
4import shutil4import shutil
5from pathlib import Path
5from subprocess import check_call, check_output6from subprocess import check_call, check_output
7from tempfile import TemporaryDirectory
6from textwrap import dedent8from textwrap import dedent
79
8import pytest10import pytest
@@ -127,3 +129,22 @@ def local_cloud():
127 if backup:129 if backup:
128 logging.info("Restoring backup")130 logging.info("Restoring backup")
129 shutil.move(local_config_dir + ".bak", local_config_dir)131 shutil.move(local_config_dir + ".bak", local_config_dir)
132
133
134@pytest.fixture
135def non_existent_directory():
136 """Return a non-existent directory."""
137 dir = Path("/a/path/that/does/not/exist")
138 assert not dir.exists()
139 return dir
140
141
142@pytest.fixture
143def non_writable_directory():
144 """Return a non-writable directory."""
145 dir = TemporaryDirectory()
146 os.chmod(dir.name, mode=0o555)
147
148 yield dir.name
149
150 dir.cleanup()
diff --git a/tests/functional/test_jujulint.py b/tests/functional/test_jujulint.py
index e15e9b6..9d908f1 100644
--- a/tests/functional/test_jujulint.py
+++ b/tests/functional/test_jujulint.py
@@ -47,3 +47,50 @@ async def test_audit_local_cloud(ops_test, local_cloud, rules_file):
47 f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr47 f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr
48 )48 )
49 assert returncode == 049 assert returncode == 0
50
51
52@pytest.mark.cloud
53async def test_output_folder(ops_test, local_cloud, rules_file, tmp_path):
54 """Test juju-lint state output to folder."""
55 all_data_yaml = tmp_path / "all-data.yaml"
56 cloudstate_yaml = tmp_path / f"{local_cloud}-state.yaml"
57 assert not all_data_yaml.exists() and not cloudstate_yaml.exists()
58
59 await ops_test.model.wait_for_idle()
60 returncode, _, stderr = await ops_test.run(
61 *f"juju-lint -d {tmp_path} -c {rules_file}".split()
62 )
63
64 assert (
65 f"[{local_cloud}] Linting model information for {socket.getfqdn()}, "
66 f"controller {ops_test.controller_name}, model {ops_test.model_name}" in stderr
67 )
68 assert returncode == 0
69 assert all_data_yaml.exists() and cloudstate_yaml.exists()
70
71
72@pytest.mark.parametrize(
73 "bad_output_folder, expected_error",
74 [
75 ("non_existent_directory", "No such file or directory"),
76 ("non_writable_directory", "Permission denied"),
77 ],
78)
79@pytest.mark.cloud
80async def test_bad_output_folder_error(
81 ops_test, local_cloud, rules_file, bad_output_folder, expected_error, request
82):
83 """Test juju-lint fails gracefully for bad output folder values."""
84 output_folder = request.getfixturevalue(bad_output_folder)
85
86 await ops_test.model.wait_for_idle()
87 returncode, _, stderr = await ops_test.run(
88 *f"juju-lint -d {output_folder} -c {rules_file}".split()
89 )
90 assert returncode != 0
91 assert (
92 f"[{local_cloud}] Linting model information for {socket.getfqdn()}, "
93 f"controller {ops_test.controller_name}, model {ops_test.model_name}"
94 not in stderr
95 )
96 assert expected_error in stderr
diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py
index 4d3d3ee..00500ea 100644
--- a/tests/unit/conftest.py
+++ b/tests/unit/conftest.py
@@ -239,6 +239,7 @@ def rules_files():
239 ]239 ]
240240
241241
242@pytest.fixture
242def juju_status_relation():243def juju_status_relation():
243 """Representation of juju status input to test relations checks."""244 """Representation of juju status input to test relations checks."""
244 return {245 return {
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index ed63f67..81d8934 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -1,5 +1,6 @@
1#!/usr/bin/python31#!/usr/bin/python3
2"""Test the CLI."""2"""Test the CLI."""
3import errno
3from logging import WARN4from logging import WARN
4from unittest.mock import MagicMock, call5from unittest.mock import MagicMock, call
56
@@ -254,7 +255,11 @@ def test_cli_audit_all(cli_instance, mocker):
254 clouds = MagicMock()255 clouds = MagicMock()
255 clouds.get.return_value = clouds_value256 clouds.get.return_value = clouds_value
256257
257 config_data = {"clouds": clouds}258 folder_value = ""
259 folder = MagicMock()
260 folder.get.return_value = folder_value
261
262 config_data = {"clouds": clouds, "output": {"folder": folder}}
258 config = MagicMock()263 config = MagicMock()
259 config.__getitem__.side_effect = config_data.__getitem__264 config.__getitem__.side_effect = config_data.__getitem__
260265
@@ -336,7 +341,7 @@ def test_cli_write_yaml(cli_instance, mocker):
336 opened_file = MagicMock()341 opened_file = MagicMock()
337 mock_open = mocker.patch("builtins.open", return_value=opened_file)342 mock_open = mocker.patch("builtins.open", return_value=opened_file)
338343
339 config = {"output": {"dump": True, "folder": output_folder}}344 config = {"output": {"folder": output_folder}}
340345
341 cli_instance.config = config346 cli_instance.config = config
342 cli_instance.write_yaml(data, file_name)347 cli_instance.write_yaml(data, file_name)
@@ -347,6 +352,37 @@ def test_cli_write_yaml(cli_instance, mocker):
347 yaml_mock.dump.assert_called_once_with(data, opened_file)352 yaml_mock.dump.assert_called_once_with(data, opened_file)
348353
349354
355def test_check_output_folder(cli_instance, mocker):
356 """Test _check_output_folder() method from Cli class."""
357 folder_value = "/a/non/empty/path/string"
358 folder = MagicMock()
359 folder.get.return_value = folder_value
360
361 config = {"output": {"folder": folder}}
362 cli_instance.config = config
363
364 mock_temporary_file = mocker.patch("tempfile.TemporaryFile")
365 mock_sys_exit = mocker.patch("sys.exit")
366
367 mock_temporary_file.return_value.__enter__.side_effect = FileNotFoundError()
368 cli_instance._check_output_folder()
369 mock_sys_exit.assert_called_once_with(errno.ENOENT)
370
371 mock_temporary_file.reset_mock(return_value=True)
372 mock_sys_exit.reset_mock()
373
374 mock_temporary_file.return_value.__enter__.side_effect = PermissionError()
375 cli_instance._check_output_folder()
376 mock_sys_exit.assert_called_once_with(errno.EACCES)
377
378 mock_temporary_file.reset_mock(return_value=True)
379 mock_sys_exit.reset_mock()
380
381 mock_temporary_file.return_value.__enter__.side_effect = Exception()
382 cli_instance._check_output_folder()
383 mock_sys_exit.assert_called_once_with(1)
384
385
350@pytest.mark.parametrize("audit_type", ["file", "all", None])386@pytest.mark.parametrize("audit_type", ["file", "all", None])
351def test_main(cli_instance, audit_type, mocker):387def test_main(cli_instance, audit_type, mocker):
352 """Test main entrypoint of jujulint."""388 """Test main entrypoint of jujulint."""

Subscribers

People subscribed via source and target branches