Skip to content

Commit

Permalink
Add support for json schema validation
Browse files Browse the repository at this point in the history
  • Loading branch information
ssbarnea committed May 4, 2022
1 parent f9ad8ea commit aee5ef9
Show file tree
Hide file tree
Showing 40 changed files with 5,011 additions and 61 deletions.
1 change: 1 addition & 0 deletions .config/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ isdir
isdisjoint
iskeyword
isort
jsonschema
junitxml
kubernetes
libera
Expand Down
11 changes: 9 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ repos:
examples/playbooks/templates/not-valid.yaml|
examples/playbooks/with-umlaut-.*|
examples/playbooks/with-skip-tag-id.yml|
test/fixtures/formatting-before/.*
test/fixtures/formatting-before/.*|
src/ansiblelint/schemas/.*
)$
additional_dependencies:
- prettier
Expand Down Expand Up @@ -72,6 +73,10 @@ repos:
rev: v2.1.0
hooks:
- id: codespell
exclude: >
(?x)^(
src/ansiblelint/schemas/.*\.json
)$
- repo: https://github.com/PyCQA/doc8
rev: 0.11.1
hooks:
Expand Down Expand Up @@ -126,10 +131,11 @@ repos:
- rich>=11.0.0
- ruamel.yaml
- sphinx>=4.4.0
- types-pyyaml>=6.0.4
- types-dataclasses
- types-docutils
- types-jsonschema>=4.4.2
- types-pkg_resources
- types-pyyaml>=6.0.4
- wcmatch
- yamllint
exclude: >
Expand All @@ -147,6 +153,7 @@ repos:
- docutils
- enrich
- flaky
- jsonschema>=4.4.0
- pytest
- pyyaml
- rich>=11.0.0
Expand Down
10 changes: 10 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import importlib
import os
import sys
from typing import Any

from ansiblelint.schemas import refresh_schemas

# checking if user is running pytest without installing test dependencies:
missing = []
Expand All @@ -18,3 +21,10 @@

os.environ["NO_COLOR"] = "1"
pytest_plugins = ["ansiblelint.testing.fixtures"]


def pytest_configure(config: Any) -> None:
"""Configure pytest."""
# run only on master node (xdist):
if not hasattr(config, "slaveinput"):
refresh_schemas()
1 change: 1 addition & 0 deletions cspell.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ignorePaths:
- docs/requirements.in
# Test fixtures generated from outside
- test/**/*.result
- src/ansiblelint/schemas/*.json
# Other
- "*.svg"
allowCompoundWords: true
2 changes: 1 addition & 1 deletion examples/galaxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ dependencies:
other_namespace.collection2: ">=2.0.0,<3.0.0"
anderson55.my_collection: "*" # note: "*" selects the highest version available
license:
- GPL
- GPL # <-- invalid license values based on galaxy schema
- Apache
1 change: 1 addition & 0 deletions examples/playbooks/command-check-failure.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
ansible.builtin.shell: echo blah
args:
chdir: X
become_method: xx
4 changes: 4 additions & 0 deletions examples/playbooks/json-schema-fail.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
- name: This should raise json-schema error, due to hosts missing the last letter
host: localhost
tasks: []
4 changes: 4 additions & 0 deletions examples/playbooks/schema-error-string.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
foo
# This file is valid YAML but from our point of view is an error, as is
# neither a Sequence or a Mapping.
10 changes: 7 additions & 3 deletions examples/playbooks/syntax-error-string.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
foo
# This file is valid YAML but from our point of view is an error, as is
# neither a Sequence or a Mapping.
# This file is valid YAML and passed JSON Schema validation but not ansible
# own syntax check.

- hosts: localhost
tasks:
- name: invalid syntax
x.y.z.w: {}
3 changes: 2 additions & 1 deletion examples/roles/dependency_in_meta/meta/main.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
# meta file, determined by ending in meta/main.yml
# https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html#role-dependencies
allow_duplicates: true
dependencies:
# from Bitbucket
- src: git+http://bitbucket.org/willthames/git-ansible-galaxy
Expand Down Expand Up @@ -35,6 +36,6 @@ galaxy_info:
description: Testing meta
company: Not applicable
license: MIT
min_ansible_version: 2.5
min_ansible_version: "2.5"
platforms:
- name: Fedora
6 changes: 3 additions & 3 deletions examples/roles/invalid_due_to_meta/meta/main.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
galaxy_info:
role_name: invalid-due-to-meta
role_name: invalid-due-to-meta # <-- invalid role name
author: foo
description: foo
license: MIT
platforms:
- name: foo
min_ansible_version: 2.7
- name: AIX
min_ansible_version: "2.7"
4 changes: 2 additions & 2 deletions examples/roles/valid-due-to-meta/meta/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ galaxy_info:
description: foo
license: MIT
platforms:
- name: foo
min_ansible_version: 2.7
- name: Fedora
min_ansible_version: "2.7"
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ importlib-metadata==4.11.3
iniconfig==1.1.1
isort==5.10.1
jinja2==3.1.2
jsonschema==4.4.0
lazy-object-proxy==1.7.1
markdown-it-py==2.1.0
markupsafe==2.1.1
Expand All @@ -56,6 +57,7 @@ pyflakes==2.4.0
pygments==2.12.0
pylint==2.13.7
pyparsing==3.0.8
pyrsistent==0.18.1
pytest==7.1.2
pytest-cov==3.0.0
pytest-forked==1.4.0
Expand Down
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ install_requires =
ansible-compat>=2.0.2 # GPLv3
ansible-core>=2.12.0 # GPLv3
enrich>=1.2.6
jsonschema>=4.4.0 # MIT
packaging
pyyaml
pytest
Expand Down
20 changes: 20 additions & 0 deletions src/ansiblelint/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# Do not sort this list, order matters.
{"jinja2": "**/*.j2"}, # jinja2 templates are not always parsable as something else
{"jinja2": "**/*.j2.*"},
{"yaml": ".github/**/*.{yaml,yml}"}, # github workflows
{"text": "**/templates/**/*.*"}, # templates are likely not validable
{"inventory": "**/inventory/**.yml"},
{"requirements": "**/meta/requirements.yml"}, # v1 only
Expand Down Expand Up @@ -55,6 +56,25 @@
]


# Maps kinds to JSON schemas
# See https://www.schemastore.org/json/
JSON_SCHEMAS = {
# playbook and task schemas not used yet due jsonschema bug:
# https://github.com/python-jsonschema/jsonschema/issues/931
# "playbook": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible.json#/definitions/playbook",
# "tasks": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible.json#/definitions/tasks",
"vars": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-vars.json",
"requirements": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-requirements.json",
"meta": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-meta.json",
"galaxy": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-galaxy.json",
# unsupported yet:
"execution-environment": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-ee.json",
"meta-runtime": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-meta-runtime.json",
"inventory": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-inventory.json",
"ansible-lint-config": "https://raw.githubusercontent.com/ansible/schemas/main/f/ansible-lint.json",
"ansible-navigator-config": "https://raw.githubusercontent.com/ansible/ansible-navigator/main/src/ansible_navigator/data/ansible-navigator.json",
}

options = Namespace(
cache_dir=None,
colored=True,
Expand Down
10 changes: 5 additions & 5 deletions src/ansiblelint/rules/risky_file_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,11 @@ def matchtask(

FAIL_INI_PERMISSION = """
- hosts: all
tasks:
- name: permissions needed if create is used
ini_file:
path: foo
create: true
tasks:
- name: permissions needed if create is used
ini_file:
path: foo
create: true
"""

FAIL_INI_PRESERVE = """
Expand Down
118 changes: 118 additions & 0 deletions src/ansiblelint/rules/validate_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Rule definition for JSON Schema Validations."""
import json
import logging
import os
import sys
from functools import lru_cache
from typing import Any, List

import yaml
from jsonschema import validate
from jsonschema.exceptions import ValidationError

from ansiblelint.config import JSON_SCHEMAS
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import AnsibleLintRule
from ansiblelint.schemas import __file__ as schemas_module

_logger = logging.getLogger(__name__)


class ValidateSchemaRule(AnsibleLintRule):
"""Perform JSON Schema Validation for known lintable kinds.
Returned errors will not include exact line numbers, but they will mention
the schema name being used as a tag, like ``playbook-schema``,
``tasks-schema``.
This rule is not skippable and stops further processing of the file.
Schema bugs should be reported towards https://github.com/ansible/schemas
project instead of ansible-lint.
If incorrect schema was picked, you might want to either:
* move the file to standard location, so its file is detected correctly.
* use ``kinds:`` option in linter config to help it pick correct file type.
"""

id = "schema"
description = __doc__
severity = "VERY_HIGH"
tags = ["core", "unskippable", "experimental"]
version_added = "v6.1.0"

@staticmethod
@lru_cache(maxsize=None)
def _get_schema(kind: str) -> Any:
"""Return the schema for the given kind."""
schema_file = os.path.dirname(schemas_module) + "/" + kind + ".json"
with open(schema_file, encoding="utf-8") as f:
return json.load(f)

@staticmethod
def process_lintable(lintable: Lintable) -> List[MatchError]:
"""Return JSON validation errors found as a list of MatchError(s)."""
result = []
if lintable.kind not in JSON_SCHEMAS:
return []

try:
validate(
instance=yaml.safe_load(lintable.content),
schema=ValidateSchemaRule._get_schema(lintable.kind),
)
except yaml.constructor.ConstructorError:
_logger.debug(
"Ignored failure to load %s for schema validation, as !vault may cause it."
)
return []
except ValidationError as exc:
result.append(
MatchError(
message=exc.message,
filename=lintable,
rule=ValidateSchemaRule(),
details=ValidateSchemaRule.description,
tag=f"schema[{lintable.kind}]",
)
)
return result


# testing code to be loaded only with pytest or when executed the rule file
if "pytest" in sys.modules:
import pytest

@pytest.mark.parametrize(
("file", "expected_kind", "expected"),
(
# (
# "examples/playbooks/json-schema-fail.yml",
# "playbook",
# [
# "examples/playbooks/json-schema-fail.yml:2:3: syntax-check 'host' is not a valid attribute for a Play"
# ],
# ),
(
"examples/galaxy.yml",
"galaxy",
["'GPL' is not one of"],
),
),
ids=(
# "playbook-fail",
"galaxy-fail",
),
)
def test_rule(file: str, expected_kind: str, expected: List[str]) -> None:
"""Validate parsing of ansible output."""
lintable = Lintable(file)
assert lintable.kind == expected_kind
results = ValidateSchemaRule.process_lintable(lintable)
assert len(results) == len(expected), results
for idx, result in enumerate(results):
assert result.filename.endswith(file)
assert expected[idx] in result.message
assert result.tag == f"schema[{expected_kind}]"
16 changes: 12 additions & 4 deletions src/ansiblelint/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable, expand_dirs_in_lintables
from ansiblelint.rules.syntax_check import AnsibleSyntaxCheckRule
from ansiblelint.rules.validate_schema import ValidateSchemaRule

if TYPE_CHECKING:
from argparse import Namespace
Expand Down Expand Up @@ -104,7 +105,7 @@ def is_excluded(self, file_path: str) -> bool:
for path in self.exclude_paths
)

def run(self) -> List[MatchError]:
def run(self) -> List[MatchError]: # noqa: C901
"""Execute the linting process."""
files: List[Lintable] = []
matches: List[MatchError] = []
Expand All @@ -116,9 +117,16 @@ def run(self) -> List[MatchError]:
self.lintables.remove(lintable)

# -- phase 1 : syntax check in parallel --
def worker(lintable: Lintable) -> List[MatchError]:
def worker(lintable: Lintable, runner: Runner = self) -> List[MatchError]:
# pylint: disable=protected-access
return AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable)
result = []
if "schema" not in runner.skip_list:
result.extend(ValidateSchemaRule.process_lintable(lintable))
if not result and lintable.kind == "playbook":
result.extend(
AnsibleSyntaxCheckRule._get_ansible_syntax_check_matches(lintable)
)
return result

# playbooks: List[Lintable] = []
for lintable in self.lintables:
Expand All @@ -127,7 +135,7 @@ def worker(lintable: Lintable) -> List[MatchError]:
files.append(lintable)

pool = multiprocessing.pool.ThreadPool(processes=multiprocessing.cpu_count())
return_list = pool.map(worker, files, chunksize=1)
return_list = pool.map(worker, self.lintables, chunksize=1)
pool.close()
pool.join()
for data in return_list:
Expand Down

0 comments on commit aee5ef9

Please sign in to comment.