Repository: ansible/ansible-lint

Issue:
Feature request - Skip specific rule(s) for a specific task
# Issue Type
- Feature request

# Ansible and Ansible Lint details

```
ansible 2.4.0.0
  config file = /root/.ansible.cfg
  configured module search path = [u'/root/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.5 (default, Nov  6 2016, 00:28:07) [GCC 4.8.5 20150623 (Red Hat 4.8.5-11)]

ansible-lint 3.4.23
```

- ansible installation method: pip
- ansible-lint installation method: pip

# Desired Behaviour

Currently, we can skip linting on a task by adding `skip_ansible_lint` as a tag to the task. But I'm looking for something more granular than that. I want to be able to skip/ignore a specific rule (ie. `ANSIBLE00XX`) on a task-level basis. For example, something like:

```yaml
- hosts: ...
  tasks:
    - name: Install stuff
      yum:
        name: ...
        state: latest
      tags:
        - skip_ansible_lint_rule_ANSIBLE0010
```

Syntax is obviously tentative and subject to change.

In this example, I may indeed want to always install the `latest` version of a package, for whatever reason. I only want to ignore ANSIBLE0010 ("Package installs should not use latest"), but keep other linting like trailing whitespace.


Discussion:
I'd love to have this - see how [shellcheck can ignore checks for a particular line](https://github.com/koalaman/shellcheck/wiki/Ignore#ignoring-one-specific-instance-in-a-file).
I think it would also be convenient to have something like `.absible-lint.ignore` in project directory.

For example, I disagree with rule 405 "Remote package tasks should have a retry". Repository mirrors I use are quite reliable, I never encountered any problems. Failure to install package usually is because the repository is not properly configured, typo in the package name, problems with dependencies or something else, that can't be solved by retry.
@selivan If I understood your situation correctly, that can be accomplished in the `.ansible-lint` [configuration file](https://docs.ansible.com/ansible-lint/configuring/configuring.html#configuration-file) using the `skip_list` property.

In my case, I want to skip specific rules per task, rather than project. Because right now if I want to silence a specific linting error on a particular task, I end up skipping all linting on that task rather than the single rule I wanted to ignore.
@Rickkwa Thanks, that should do the trick.

Resolving patch:
diff --git a/lib/ansiblelint/__init__.py b/lib/ansiblelint/__init__.py
--- a/lib/ansiblelint/__init__.py
+++ b/lib/ansiblelint/__init__.py
@@ -57,53 +57,81 @@ def matchlines(self, file, text):
         for (prev_line_no, line) in enumerate(text.split("\n")):
             if line.lstrip().startswith('#'):
                 continue
+
+            rule_id_list = ansiblelint.utils.get_rule_skips_from_line(line)
+            if self.id in rule_id_list:
+                continue
+
             result = self.match(file, line)
-            if result:
-                message = None
-                if isinstance(result, str):
-                    message = result
-                matches.append(Match(prev_line_no+1, line,
-                               file['path'], self, message))
+            if not result:
+                continue
+            message = None
+            if isinstance(result, six.string_types):
+                message = result
+            matches.append(Match(prev_line_no+1, line,
+                           file['path'], self, message))
         return matches
 
     def matchtasks(self, file, text):
         matches = []
         if not self.matchtask:
             return matches
+
         yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path'])
-        if yaml:
-            for task in ansiblelint.utils.get_normalized_tasks(yaml, file):
-                if 'action' in task:
-                    result = self.matchtask(file, task)
-                    if result:
-                        message = None
-                        if isinstance(result, six.string_types):
-                            message = result
-                        taskstr = "Task/Handler: " + ansiblelint.utils.task_to_str(task)
-                        matches.append(Match(task[ansiblelint.utils.LINE_NUMBER_KEY], taskstr,
-                                       file['path'], self, message))
+        if not yaml:
+            return matches
+
+        yaml = ansiblelint.utils.append_skipped_rules(yaml, text, file['type'])
+
+        for task in ansiblelint.utils.get_normalized_tasks(yaml, file):
+            if self.id in task.get('skipped_rules', ()):
+                continue
+
+            if 'action' not in task:
+                continue
+            result = self.matchtask(file, task)
+            if not result:
+                continue
+
+            message = None
+            if isinstance(result, six.string_types):
+                message = result
+            task_msg = "Task/Handler: " + ansiblelint.utils.task_to_str(task)
+            matches.append(Match(task[ansiblelint.utils.LINE_NUMBER_KEY], task_msg,
+                           file['path'], self, message))
         return matches
 
     def matchyaml(self, file, text):
         matches = []
         if not self.matchplay:
             return matches
+
         yaml = ansiblelint.utils.parse_yaml_linenumbers(text, file['path'])
-        if yaml and hasattr(self, 'matchplay'):
-            if isinstance(yaml, dict):
-                yaml = [yaml]
-            for play in yaml:
-                result = self.matchplay(file, play)
-                if result:
-                    if isinstance(result, tuple):
-                        result = [result]
-
-                    if not isinstance(result, list):
-                        raise Exception("{} is not a list".format(result))
-
-                    for section, message in result:
-                        matches.append(Match(play[ansiblelint.utils.LINE_NUMBER_KEY],
-                                             section, file['path'], self, message))
+        if not yaml:
+            return matches
+
+        if isinstance(yaml, dict):
+            yaml = [yaml]
+
+        yaml = ansiblelint.utils.append_skipped_rules(yaml, text, file['type'])
+
+        for play in yaml:
+            if self.id in play.get('skipped_rules', ()):
+                continue
+
+            result = self.matchplay(file, play)
+            if not result:
+                continue
+
+            if isinstance(result, tuple):
+                result = [result]
+
+            if not isinstance(result, list):
+                raise TypeError("{} is not a list".format(result))
+
+            for section, message in result:
+                matches.append(Match(play[ansiblelint.utils.LINE_NUMBER_KEY],
+                                     section, file['path'], self, message))
         return matches
 
 
diff --git a/lib/ansiblelint/utils.py b/lib/ansiblelint/utils.py
--- a/lib/ansiblelint/utils.py
+++ b/lib/ansiblelint/utils.py
@@ -37,6 +37,7 @@
 import yaml
 from yaml.composer import Composer
 from yaml.constructor import Constructor
+import ruamel.yaml
 
 try:
     from ansible.utils import parse_yaml_from_file
@@ -592,3 +593,74 @@ def get_first_cmd_arg(task):
     except IndexError:
         return None
     return first_cmd_arg
+
+
+def append_skipped_rules(pyyaml_data, file_text, file_type):
+    """ Uses ruamel.yaml to parse comments then adds a
+        skipped_rules list to the task (or meta yaml block)
+    """
+    yaml = ruamel.yaml.YAML()
+    ruamel_data = yaml.load(file_text)
+
+    if file_type in ('tasks', 'handlers'):
+        ruamel_tasks = ruamel_data
+        pyyaml_tasks = pyyaml_data
+    elif file_type == 'playbook':
+        try:
+            ruamel_tasks = []
+            pyyaml_tasks = []
+            for ruamel_play, pyyaml_play in zip(ruamel_data, pyyaml_data):
+                ruamel_tasks.extend(ruamel_play.get('tasks'))
+                pyyaml_tasks.extend(pyyaml_play.get('tasks'))
+        except (AttributeError, TypeError):
+            return pyyaml_data
+    elif file_type == 'meta':
+        if not isinstance(pyyaml_data, list):
+            return pyyaml_data
+        ruamel_tasks = [ruamel_data]
+        pyyaml_tasks = pyyaml_data
+    else:
+        return pyyaml_data
+
+    if len(ruamel_tasks) != len(pyyaml_tasks):
+        return pyyaml_data
+
+    for ruamel_task, pyyaml_task in zip(ruamel_tasks, pyyaml_tasks):
+        skipped_rules = _get_rule_skips_from_task(ruamel_task)
+        if skipped_rules:
+            pyyaml_task['skipped_rules'] = skipped_rules
+
+    return pyyaml_data
+
+
+def _get_rule_skips_from_task(task):
+    def traverse_yaml(obj):
+        task_comment_obj_strs.append(str(obj.ca.items))
+        if isinstance(obj, dict):
+            for key, val in obj.items():
+                if isinstance(val, (dict, list)):
+                    traverse_yaml(val)
+        elif isinstance(obj, list):
+            for e in obj:
+                if isinstance(e, (dict, list)):
+                    traverse_yaml(e)
+        else:
+            return
+
+    task_comment_obj_strs = []
+    traverse_yaml(task)
+
+    rule_id_list = []
+    for comment_obj_str in task_comment_obj_strs:
+        for line in comment_obj_str.split('\\n'):
+            rule_id_list.extend(get_rule_skips_from_line(line))
+
+    return rule_id_list
+
+
+def get_rule_skips_from_line(line):
+    rule_id_list = []
+    if '# noqa' in line:
+        noqa_text = line.split('# noqa')[1]
+        rule_id_list = noqa_text.split()
+    return rule_id_list


Test patch:
diff --git a/test/TestSkipInsideYaml.py b/test/TestSkipInsideYaml.py
new file mode 100644
--- /dev/null
+++ b/test/TestSkipInsideYaml.py
@@ -0,0 +1,114 @@
+import unittest
+import os
+
+from ansiblelint import RulesCollection
+from test import RunFromText
+
+
+ROLE_TASKS = '''
+---
+- name: test 303
+  command: git log
+  changed_when: False
+- name: test 303 (skipped)
+  command: git log # noqa 303
+  changed_when: False
+
+- name: test 401 and 501
+  become_user: alice
+  action: git
+- name: test 401 and 501 (skipped)  # noqa 401 501
+  become_user: alice
+  action: git
+- name: test 401 and 501 (skipped via skip_ansible_lint)
+  become_user: alice
+  action: git
+  tags:
+    skip_ansible_lint
+
+- name: test 601
+  debug:
+    msg: testing
+  when: my_var == true
+- name: test 601 (skipped)
+  debug:
+    msg: testing
+  when: my_var == true  # noqa 601
+
+- name: test 303
+  command: git log
+  changed_when: False
+- name: test 303 (skipped via no warn)
+  command: git log
+  args:
+    warn: False
+  changed_when: False
+'''
+
+PLAYBOOK = '''
+- hosts: all
+  tasks:
+    - name: test 402
+      action: hg
+    - name: test 402 (skipped)  # noqa 402
+      action: hg
+
+    - name: test 401 and 501
+      become_user: alice
+      action: git
+    - name: test 401 and 501 (skipped)  # noqa 401 501
+      become_user: alice
+      action: git
+
+    - name: test 204 and 206
+      get_url:
+        url: http://example.com/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/file.conf
+        dest: "{{dest_proj_path}}/foo.conf"
+    - name: test 204 and 206 (skipped)
+      get_url:
+        url: http://example.com/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/really_long_path/file.conf  # noqa 204
+        dest: "{{dest_proj_path}}/foo.conf"  # noqa 206
+
+    - name: test 302
+      command: creates=B chmod 644 A
+    - name: test 302
+      command: warn=yes creates=B chmod 644 A
+    - name: test 302 (skipped via no warn)
+      command: warn=no creates=B chmod 644 A
+    - name: test 302 (skipped via skip_ansible_lint)
+      command: creates=B chmod 644 A
+      tags:
+        - skip_ansible_lint
+
+    - name: test invalid action (skipped)
+      foo: bar
+      tags:
+        - skip_ansible_lint
+'''
+
+ROLE_META = '''
+galaxy_info:  # noqa 701
+  author: your name  # noqa 703
+  description: missing min_ansible_version and platforms. author default not changed
+  license: MIT
+'''
+
+
+class TestSkipInsideYaml(unittest.TestCase):
+    rulesdir = os.path.join('lib', 'ansiblelint', 'rules')
+    collection = RulesCollection.create_from_directory(rulesdir)
+
+    def setUp(self):
+        self.runner = RunFromText(self.collection)
+
+    def test_role_tasks(self):
+        results = self.runner.run_role_tasks_main(ROLE_TASKS)
+        self.assertEqual(5, len(results))
+
+    def test_playbook(self):
+        results = self.runner.run_playbook(PLAYBOOK)
+        self.assertEqual(8, len(results))
+
+    def test_role_meta(self):
+        results = self.runner.run_role_meta_main(ROLE_META)
+        self.assertEqual(0, len(results))
diff --git a/test/TestSkippedTasks.py b/test/TestSkippedTasks.py
deleted file mode 100644
--- a/test/TestSkippedTasks.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# Copyright (c) 2013-2014 Will Thames <will@thames.id.au>
-#               2014      Akira Yoshiyama <akirayoshiyama@gmail.com>
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-import os
-import unittest
-
-import ansiblelint
-from ansiblelint import Runner, RulesCollection
-
-
-class TestRule(unittest.TestCase):
-
-    def setUp(self):
-        rulesdir = os.path.join('lib', 'ansiblelint', 'rules')
-        self.rules = RulesCollection.create_from_directory(rulesdir)
-
-    def test_runner_count(self):
-        filename = 'test/skiptasks.yml'
-        tags = ['401', '402', '303', '302']
-        runner = Runner(self.rules, filename, tags, [], [])
-        self.assertEqual(len(runner.run()), 6)