Repository: ansible/ansible-lint

Issue:
Relative path in config file are not expanded in a deterministic way
# Issue Type
- Bug report

# Ansible and Ansible Lint details

```
ansible 2.8.3
  config file = /home/ncaniart/src/gitco/roles/user-make/ansible.cfg
  configured module search path = ['/home/ncaniart/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /home/ncaniart/.virtualenvs/ansible-roling/lib/python3.7/site-packages/ansible
  executable location = /home/ncaniart/.virtualenvs/ansible-roling/bin/ansible
  python version = 3.7.3 (default, Apr  3 2019, 05:39:12) [GCC 8.3.0]

ansible-lint 4.1.0a1.dev28+g6388f1c.d20190818
```

- ansible installation method: pip
- ansible-lint installation method: source (master)

# Desired Behaviour

The path listed in ansible-lint configuration file should be expanded relatively to the configuration file path, not the current working directory. Otherwise linting output depends on the environment.

# Actual Behaviour (Bug report only)

Using the role github.com/cans/user-make.git as an example:

```
git clone git@github.com:cans/user-make.git
cd user-make
ansible-galaxy install tests/requirements.yml
ansible-lint -c ./.ansible-lint tests/local.yml
```
Yields a proper output (roles under tests/roles are not linted)
```
cd tests
ansible-lint -c ../.ansible-lint local.yml
```
Reports warnings about the third party roles, e.g.:
```
[206] Variables should have spaces before and after: {{ var_name }}
tests/roles/chrismeyersfsu.provision_docker/tasks/inc_cloud_iface.yml:10
    tty: "{{provision_docker_use_docker_connection | bool}}"
```

2019-11-03: Fixed commands to reproduce the issue. One needs to lint a specific playbook to make the issue more obvious.


Discussion:

Resolving patch:
diff --git a/lib/ansiblelint/__main__.py b/lib/ansiblelint/__main__.py
--- a/lib/ansiblelint/__main__.py
+++ b/lib/ansiblelint/__main__.py
@@ -23,122 +23,18 @@
 from __future__ import print_function
 
 import errno
-import optparse
 import sys
 
 import ansiblelint.formatters as formatters
 import six
-from ansiblelint import default_rulesdir, RulesCollection, Runner
-from ansiblelint.version import __version__
-from ansiblelint.utils import get_playbooks_and_roles, normpath
-import yaml
-import os
-
-
-def load_config(config_file):
-    config_path = config_file if config_file else ".ansible-lint"
-
-    if os.path.exists(config_path):
-        with open(config_path, "r") as stream:
-            try:
-                return yaml.safe_load(stream)
-            except yaml.YAMLError:
-                pass
-
-    return None
+from ansiblelint import cli, default_rulesdir, RulesCollection, Runner
+from ansiblelint.utils import normpath
 
 
 def main():
 
     formatter = formatters.Formatter()
-
-    parser = optparse.OptionParser("%prog [options] [playbook.yml [playbook2 ...]]|roledirectory",
-                                   version="%prog " + __version__)
-
-    parser.add_option('-L', dest='listrules', default=False,
-                      action='store_true', help="list all the rules")
-    parser.add_option('-q', dest='quiet',
-                      default=False,
-                      action='store_true',
-                      help="quieter, although not silent output")
-    parser.add_option('-p', dest='parseable',
-                      default=False,
-                      action='store_true',
-                      help="parseable output in the format of pep8")
-    parser.add_option('--parseable-severity', dest='parseable_severity',
-                      default=False,
-                      action='store_true',
-                      help="parseable output including severity of rule")
-    parser.add_option('-r', action='append', dest='rulesdir',
-                      default=[], type='str',
-                      help="specify one or more rules directories using "
-                           "one or more -r arguments. Any -r flags override "
-                           "the default rules in %s, unless -R is also used."
-                           % default_rulesdir)
-    parser.add_option('-R', action='store_true',
-                      default=False,
-                      dest='use_default_rules',
-                      help="Use default rules in %s in addition to any extra "
-                           "rules directories specified with -r. There is "
-                           "no need to specify this if no -r flags are used"
-                           % default_rulesdir)
-    parser.add_option('-t', dest='tags',
-                      action='append',
-                      default=[],
-                      help="only check rules whose id/tags match these values")
-    parser.add_option('-T', dest='listtags', action='store_true',
-                      help="list all the tags")
-    parser.add_option('-v', dest='verbosity', action='count',
-                      help="Increase verbosity level",
-                      default=0)
-    parser.add_option('-x', dest='skip_list', default=[], action='append',
-                      help="only check rules whose id/tags do not " +
-                      "match these values")
-    parser.add_option('--nocolor', dest='colored',
-                      default=hasattr(sys.stdout, 'isatty') and sys.stdout.isatty(),
-                      action='store_false',
-                      help="disable colored output")
-    parser.add_option('--force-color', dest='colored',
-                      action='store_true',
-                      help="Try force colored output (relying on ansible's code)")
-    parser.add_option('--exclude', dest='exclude_paths', action='append',
-                      help='path to directories or files to skip. This option'
-                           ' is repeatable.',
-                      default=[])
-    parser.add_option('-c', dest='config_file',
-                      help='Specify configuration file to use.  Defaults to ".ansible-lint"')
-    options, args = parser.parse_args(sys.argv[1:])
-
-    config = load_config(options.config_file)
-
-    if config:
-        if 'quiet' in config:
-            options.quiet = options.quiet or config['quiet']
-
-        if 'parseable' in config:
-            options.parseable = options.parseable or config['parseable']
-
-        if 'parseable_severity' in config:
-            options.parseable_severity = options.parseable_severity or \
-                config['parseable_severity']
-
-        if 'use_default_rules' in config:
-            options.use_default_rules = options.use_default_rules or config['use_default_rules']
-
-        if 'verbosity' in config:
-            options.verbosity = options.verbosity + config['verbosity']
-
-        options.exclude_paths.extend(
-            config.get('exclude_paths', []))
-
-        if 'rulesdir' in config:
-            options.rulesdir = options.rulesdir + config['rulesdir']
-
-        if 'skip_list' in config:
-            options.skip_list = options.skip_list + config['skip_list']
-
-        if 'tags' in config:
-            options.tags = options.tags + config['tags']
+    options, args = cli.get_config(sys.argv[1:])
 
     if options.quiet:
         formatter = formatters.QuietFormatter()
@@ -151,7 +47,8 @@ def main():
 
     # no args triggers auto-detection mode
     if len(args) == 0 and not (options.listrules or options.listtags):
-        args = get_playbooks_and_roles(options=options)
+        cli.print_help(file=sys.stderr)
+        return 1
 
     if options.use_default_rules:
         rulesdirs = options.rulesdir + [default_rulesdir]
diff --git a/lib/ansiblelint/cli.py b/lib/ansiblelint/cli.py
new file mode 100644
--- /dev/null
+++ b/lib/ansiblelint/cli.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+import optparse
+import os
+import sys
+
+import yaml
+
+import ansiblelint
+from ansiblelint.version import __version__
+
+
+_PATH_VARS = ['exclude_paths', 'rulesdir', ]
+
+
+def abspath(path, base_dir):
+    """Make relative path absolute relative to given directory.
+
+    Args:
+       path (str): the path to make absolute
+       base_dir (str): the directory from which make relative paths
+           absolute
+       default_drive: Windows drive to use to make the path
+           absolute if none is given.
+    """
+    if not os.path.isabs(path):
+        # Don't use abspath as it assumes path is relative to cwd.
+        # We want it relative to base_dir.
+        path = os.path.join(base_dir, path)
+
+    return os.path.normpath(path)
+
+
+def expand_to_normalized_paths(config, base_dir=None):
+    base_dir = base_dir or os.getcwd()
+    for paths_var in _PATH_VARS:
+        if paths_var not in config:
+            continue  # Cause we don't want to add a variable not present
+
+        normalized_paths = []
+        for path in config.pop(paths_var):
+            normalized_path = abspath(path, base_dir=base_dir)
+
+            normalized_paths.append(normalized_path)
+
+        config[paths_var] = normalized_paths
+
+
+def load_config(config_file):
+    config_path = config_file if config_file else ".ansible-lint"
+    config_path = os.path.abspath(config_path)
+
+    if not os.path.exists(config_path):
+        return
+
+    try:
+        with open(config_path, "r") as stream:
+            config = yaml.safe_load(stream)
+    except yaml.YAMLError:
+        return None
+
+    config_dir = os.path.dirname(config_path)
+    expand_to_normalized_paths(config, config_dir)
+    return config
+
+
+def abspath_arg(option, opt_str, value, parser, *args, **kwargs):
+    getattr(parser.values, option.dest).append(os.path.abspath(value))
+
+
+def get_cli_parser():
+    parser = optparse.OptionParser("%prog [options] [playbook.yml [playbook2 ...]]|roledirectory",
+                                   version="%prog " + __version__)
+
+    parser.add_option('-L', dest='listrules', default=False,
+                      action='store_true', help="list all the rules")
+    parser.add_option('-q', dest='quiet',
+                      default=False,
+                      action='store_true',
+                      help="quieter, although not silent output")
+    parser.add_option('-p', dest='parseable',
+                      default=False,
+                      action='store_true',
+                      help="parseable output in the format of pep8")
+    parser.add_option('--parseable-severity', dest='parseable_severity',
+                      default=False,
+                      action='store_true',
+                      help="parseable output including severity of rule")
+    parser.add_option('-r', action='callback', dest='rulesdir',
+                      default=[], type='str', callback=abspath_arg,
+                      help="specify one or more rules directories using "
+                           "one or more -r arguments. Any -r flags override "
+                           "the default rules in %s, unless -R is also used."
+                           % ansiblelint.default_rulesdir)
+    parser.add_option('-R', action='store_true',
+                      default=False,
+                      dest='use_default_rules',
+                      help="Use default rules in %s in addition to any extra "
+                           "rules directories specified with -r. There is "
+                           "no need to specify this if no -r flags are used"
+                           % ansiblelint.default_rulesdir)
+    parser.add_option('-t', dest='tags',
+                      action='append',
+                      default=[],
+                      help="only check rules whose id/tags match these values")
+    parser.add_option('-T', dest='listtags', action='store_true',
+                      help="list all the tags")
+    parser.add_option('-v', dest='verbosity', action='count',
+                      help="Increase verbosity level",
+                      default=0)
+    parser.add_option('-x', dest='skip_list', default=[], action='append',
+                      help="only check rules whose id/tags do not " +
+                      "match these values")
+    parser.add_option('--nocolor', dest='colored',
+                      default=hasattr(sys.stdout, 'isatty') and sys.stdout.isatty(),
+                      action='store_false',
+                      help="disable colored output")
+    parser.add_option('--force-color', dest='colored',
+                      action='store_true',
+                      help="Try force colored output (relying on ansible's code)")
+    parser.add_option('--exclude', dest='exclude_paths', action='callback',
+                      callback=abspath_arg, type=str, default=[],
+                      help='path to directories or files to skip. This option'
+                           ' is repeatable.',
+                      )
+    parser.add_option('-c', dest='config_file',
+                      help='Specify configuration file to use.  Defaults to ".ansible-lint"')
+
+    return parser
+
+
+def merge_config(file_config, cli_config):
+    if not file_config:
+        return cli_config
+
+    if 'quiet' in file_config:
+        cli_config.quiet = cli_config.quiet or file_config['quiet']
+
+    if 'parseable' in file_config:
+        cli_config.parseable = (cli_config.parseable or
+                                file_config['parseable'])
+
+    if 'parseable_severity' in file_config:
+        cli_config.parseable_severity = (cli_config.parseable_severity or
+                                         file_config['parseable_severity'])
+
+    if 'use_default_rules' in file_config:
+        cli_config.use_default_rules = (cli_config.use_default_rules or
+                                        file_config['use_default_rules'])
+
+    if 'verbosity' in file_config:
+        cli_config.verbosity = (cli_config.verbosity +
+                                file_config['verbosity'])
+
+    cli_config.exclude_paths.extend(file_config.get('exclude_paths', []))
+
+    cli_config.rulesdir.extend(file_config.get('rulesdir', []))
+
+    if 'skip_list' in file_config:
+        cli_config.skip_list = cli_config.skip_list + file_config['skip_list']
+
+    if 'tags' in file_config:
+        cli_config.tags = cli_config.tags + file_config['tags']
+
+    return cli_config
+
+
+def get_config(arguments):
+    parser = get_cli_parser()
+    options, args = parser.parse_args(arguments)
+
+    config = load_config(options.config_file)
+
+    return merge_config(config, options), args
+
+
+def print_help(file=sys.stdout):
+    get_cli_parser().print_help(file=file)
+
+
+# vim: et:sw=4:syntax=python:ts=4:


Test patch:
diff --git a/test/TestCommandLineInvocationSameAsConfig.py b/test/TestCommandLineInvocationSameAsConfig.py
--- a/test/TestCommandLineInvocationSameAsConfig.py
+++ b/test/TestCommandLineInvocationSameAsConfig.py
@@ -1,82 +1,72 @@
-import unittest
-from subprocess import Popen, PIPE
-import os
-import shutil
-import yaml
+import pytest
 
+from ansiblelint import cli
 
-class TestCommandLineInvocationSameAsConfig(unittest.TestCase):
-    def setUp(self):
-        if os.path.exists(".sandbox"):
-            shutil.rmtree(".sandbox")
 
-        os.makedirs(".sandbox/subdir")
+@pytest.fixture
+def base_arguments():
+    return ['../test/skiptasks.yml']
 
-    def run_ansible_lint(self, args=False, config=None):
-        command = "cd .sandbox; ../bin/ansible-lint ../test/skiptasks.yml"
-        if args:
-            command += " " + args
 
-        if config:
-            with open(".sandbox/.ansible-lint", "w") as outfile:
-                yaml.dump(config, outfile, default_flow_style=False)
+@pytest.mark.parametrize('args,config', [
+                         (["-p"], "test/fixtures/parseable.yml"),
+                         (["-q"], "test/fixtures/quiet.yml"),
+                         (["-r", "test/fixtures/rules/"],
+                          "test/fixtures/rulesdir.yml"),
+                         (["-R", "-r", "test/fixtures/rules/"],
+                          "test/fixtures/rulesdir-defaults.yml"),
+                         (["-t", "skip_ansible_lint"],
+                          "test/fixtures/tags.yml"),
+                         (["-v"], "test/fixtures/verbosity.yml"),
+                         (["-x", "bad_tag"],
+                          "test/fixtures/skip-tags.yml"),
+                         (["--exclude", "test/"],
+                          "test/fixtures/exclude-paths.yml"),
+                         ])
+def test_ensure_config_are_equal(base_arguments, args, config):
+    command = base_arguments + args
+    options, _ = cli.get_cli_parser().parse_args(command)
 
-        result, err = Popen(
-            [command],
-            stdin=PIPE,
-            stdout=PIPE,
-            stderr=PIPE,
-            shell=True
-        ).communicate()
+    file_config = cli.load_config(config)
 
-        self.assertFalse(err, "Expected no error but was " + str(err))
+    for key in file_config.keys():
+        assert file_config[key] == getattr(options, key)
 
-        return result
 
-    def assert_config_for(self, cli_arg, config):
-        with_arg = self.run_ansible_lint(args=cli_arg)
-        with_config = self.run_ansible_lint(config=config)
+def test_config_can_be_overridden(base_arguments):
+    no_override, _ = cli.get_config(base_arguments + ["-t", "bad_tag"])
 
-        self.assertEqual(with_arg, with_config)
+    overridden, _ = cli.get_config(base_arguments +
+                                   ["-t", "bad_tag",
+                                    "-c", "test/fixtures/tags.yml"])
 
-    def test_parseable_as_config(self):
-        self.assert_config_for("-p", dict(parseable=True))
+    assert no_override.tags + ["skip_ansible_lint"] == overridden.tags
 
-    def test_quiet_as_config(self):
-        self.assert_config_for("-q", dict(quiet=True))
 
-    def test_rulesdir_as_config(self):
-        self.assert_config_for("-r ../test/rules/", dict(rulesdir=["../test/rules/"]))
+def test_different_config_file(base_arguments):
+    """Ensures an alternate config_file can be used."""
+    diff_config, _ = cli.get_config(base_arguments +
+                                    ["-c", "test/fixtures/ansible-config.yml"])
+    no_config, _ = cli.get_config(base_arguments + ["-v"])
 
-    def test_use_default_rules(self):
-        self.assert_config_for("-R -r ../test/rules/", dict(rulesdir=["../test/rules"],
-                                                            use_default_rules=True))
+    assert diff_config.verbosity == no_config.verbosity
 
-    def test_tags(self):
-        self.assert_config_for("-t skip_ansible_lint", dict(tags=["skip_ansible_lint"]))
 
-    def test_verbosity(self):
-        self.assert_config_for("-v", dict(verbosity=1))
+def test_path_from_config_do_not_depend_on_cwd(monkeypatch):  # Issue 572
+    config1 = cli.load_config("test/fixtures/config-with-relative-path.yml")
+    monkeypatch.chdir('test')
+    config2 = cli.load_config("fixtures/config-with-relative-path.yml")
 
-    def test_skip_list(self):
-        self.assert_config_for("-x bad_tag", dict(skip_list=["bad_tag"]))
+    assert config1['exclude_paths'].sort() == config2['exclude_paths'].sort()
 
-    def test_exclude(self):
-        self.assert_config_for("--exclude ../test/", dict(exclude_paths=["../test/"]))
 
-    def test_config_can_be_overridden(self):
-        no_override = self.run_ansible_lint(args="-t bad_tag")
-        overridden = self.run_ansible_lint(
-            args="-t bad_tag",
-            config=dict(tags=["skip_ansible_lint"]))
+def test_path_from_cli_depend_on_cwd(base_arguments, monkeypatch):  # Issue 572
+    arguments = base_arguments + ["--exclude",
+                                  "test/fixtures/config-with-relative-path.yml"]
 
-        self.assertEqual(no_override, overridden)
+    options1, _ = cli.get_cli_parser().parse_args(arguments)
+    monkeypatch.chdir('test')
+    options2, _ = cli.get_cli_parser().parse_args(arguments)
 
-    def test_different_config_file(self):
-        with open(".sandbox/subdir/ansible-config.yml", "w") as outfile:
-            yaml.dump(dict(verbosity=1), outfile, default_flow_style=False)
-
-        diff_config = self.run_ansible_lint(args="-c ./subdir/ansible-config.yml")
-        no_config = self.run_ansible_lint(args="-v")
-
-        self.assertEqual(diff_config, no_config)
+    assert 'test/test' not in options1.exclude_paths[0]
+    assert 'test/test' in options2.exclude_paths[0]
diff --git a/test/fixtures/ansible-config.yml b/test/fixtures/ansible-config.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/ansible-config.yml
@@ -0,0 +1,4 @@
+---
+verbosity: 1
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/config-with-relative-path.yml b/test/fixtures/config-with-relative-path.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/config-with-relative-path.yml
@@ -0,0 +1,5 @@
+---
+exclude_paths:
+- ../test-role/
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/exclude-paths.yml b/test/fixtures/exclude-paths.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/exclude-paths.yml
@@ -0,0 +1,5 @@
+---
+exclude_paths:
+- ../
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/parseable.yml b/test/fixtures/parseable.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/parseable.yml
@@ -0,0 +1,4 @@
+---
+parseable: true
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/quiet.yml b/test/fixtures/quiet.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/quiet.yml
@@ -0,0 +1,4 @@
+---
+quiet: true
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/rulesdir-defaults.yml b/test/fixtures/rulesdir-defaults.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/rulesdir-defaults.yml
@@ -0,0 +1,6 @@
+---
+rulesdir:
+- ./rules
+use_default_rules: true
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/rulesdir.yml b/test/fixtures/rulesdir.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/rulesdir.yml
@@ -0,0 +1,5 @@
+---
+rulesdir:
+- ./rules
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/skip-tags.yml b/test/fixtures/skip-tags.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/skip-tags.yml
@@ -0,0 +1,5 @@
+---
+skip_list:
+- "bad_tag"
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/tags.yml
@@ -0,0 +1,5 @@
+---
+tags:
+  - skip_ansible_lint
+
+# vim: et:sw=2:syntax=yaml:ts=2:
diff --git a/test/fixtures/verbosity.yml b/test/fixtures/verbosity.yml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/verbosity.yml
@@ -0,0 +1,4 @@
+---
+verbosity: 1
+
+# vim: et:sw=2:syntax=yaml:ts=2: