Merge ~chad.smith/cloud-init:cleanup/schema-annotate-invalid-yaml into cloud-init:master

Proposed by Chad Smith
Status: Merged
Approved by: Scott Moser
Approved revision: 142f14337d5583b69160cd66160218483b86fcbe
Merge reported by: Scott Moser
Merged at revision: 3b28bdc616f3e7f4d6b419629dc7b9efc3ae8d1e
Proposed branch: ~chad.smith/cloud-init:cleanup/schema-annotate-invalid-yaml
Merge into: cloud-init:master
Diff against target: 274 lines (+118/-27)
4 files modified
cloudinit/config/schema.py (+44/-16)
cloudinit/util.py (+14/-2)
tests/unittests/test_handler/test_schema.py (+33/-6)
tests/unittests/test_util.py (+27/-3)
Reviewer Review Type Date Requested Status
Scott Moser Approve
Server Team CI bot continuous-integration Approve
Review via email: mp+346415@code.launchpad.net

Commit message

yaml_load/schema: Add invalid line and column nums to error message

Yaml tracebacks are generally hard to read for average users. Add a bit of
logic to util.yaml_load and schema validation to look for
YAMLError.context_marker or problem_marker line and column counts.

No longer log the full exceeption traceback from the yaml_load error,
instead just LOG.warning for the specific error and point to the offending
line and column where the problem exists.

Description of the change

to test:

cat > noheader <<EOF
#notcloud-config-header
{}
EOF

cat > badparse <<EOF
#cloud-config
{}}
EOF

cat > badscan <<EOF
#cloud-config
asdf:
needindent
EOF

for badfile in noheader badparse badscan; do
    python3 -m cloudinit.cmd.main devel schema --annotate --config $badfile
done

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:51169e63748bcea4be108a62112b0ea935ee04d9
https://jenkins.ubuntu.com/server/job/cloud-init-ci/13/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/13/rebuild

review: Needs Fixing (continuous-integration)
0701568... by Chad Smith

exit non-zero and print error when config-file is absent

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:07015686ef0e77513d842ae4391613a95852fbb8
https://jenkins.ubuntu.com/server/job/cloud-init-ci/14/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/14/rebuild

review: Needs Fixing (continuous-integration)
7706458... by Chad Smith

fix unit test. py3 str(str) reports class instead of type

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Continuous integration, rev:7706458fbed6462811bdaf5838a8883802f8d19a
https://jenkins.ubuntu.com/server/job/cloud-init-ci/15/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/15/rebuild

review: Needs Fixing (continuous-integration)
142f143... by Chad Smith

rename unit test per lint error

Revision history for this message
Server Team CI bot (server-team-bot) wrote :

PASSED: Continuous integration, rev:142f14337d5583b69160cd66160218483b86fcbe
https://jenkins.ubuntu.com/server/job/cloud-init-ci/16/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/16/rebuild

review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

looks good. thanks.

review: Approve
Revision history for this message
Scott Moser (smoser) wrote :

An upstream commit landed for this bug.

To view that commit see the following URL:
https://git.launchpad.net/cloud-init/commit/?id=3b28bdc6

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/cloudinit/config/schema.py b/cloudinit/config/schema.py
2index 3bad8e2..080a6d0 100644
3--- a/cloudinit/config/schema.py
4+++ b/cloudinit/config/schema.py
5@@ -93,20 +93,33 @@ def validate_cloudconfig_schema(config, schema, strict=False):
6 def annotated_cloudconfig_file(cloudconfig, original_content, schema_errors):
7 """Return contents of the cloud-config file annotated with schema errors.
8
9- @param cloudconfig: YAML-loaded object from the original_content.
10+ @param cloudconfig: YAML-loaded dict from the original_content or empty
11+ dict if unparseable.
12 @param original_content: The contents of a cloud-config file
13 @param schema_errors: List of tuples from a JSONSchemaValidationError. The
14 tuples consist of (schemapath, error_message).
15 """
16 if not schema_errors:
17 return original_content
18- schemapaths = _schemapath_for_cloudconfig(cloudconfig, original_content)
19+ schemapaths = {}
20+ if cloudconfig:
21+ schemapaths = _schemapath_for_cloudconfig(
22+ cloudconfig, original_content)
23 errors_by_line = defaultdict(list)
24 error_count = 1
25 error_footer = []
26 annotated_content = []
27 for path, msg in schema_errors:
28- errors_by_line[schemapaths[path]].append(msg)
29+ match = re.match(r'format-l(?P<line>\d+)\.c(?P<col>\d+).*', path)
30+ if match:
31+ line, col = match.groups()
32+ errors_by_line[int(line)].append(msg)
33+ else:
34+ col = None
35+ errors_by_line[schemapaths[path]].append(msg)
36+ if col is not None:
37+ msg = 'Line {line} column {col}: {msg}'.format(
38+ line=line, col=col, msg=msg)
39 error_footer.append('# E{0}: {1}'.format(error_count, msg))
40 error_count += 1
41 lines = original_content.decode().split('\n')
42@@ -142,18 +155,31 @@ def validate_cloudconfig_file(config_path, schema, annotate=False):
43 content = load_file(config_path, decode=False)
44 if not content.startswith(CLOUD_CONFIG_HEADER):
45 errors = (
46- ('header', 'File {0} needs to begin with "{1}"'.format(
47+ ('format-l1.c1', 'File {0} needs to begin with "{1}"'.format(
48 config_path, CLOUD_CONFIG_HEADER.decode())),)
49- raise SchemaValidationError(errors)
50-
51+ error = SchemaValidationError(errors)
52+ if annotate:
53+ print(annotated_cloudconfig_file({}, content, error.schema_errors))
54+ raise error
55 try:
56 cloudconfig = yaml.safe_load(content)
57- except yaml.parser.ParserError as e:
58- errors = (
59- ('format', 'File {0} is not valid yaml. {1}'.format(
60- config_path, str(e))),)
61- raise SchemaValidationError(errors)
62-
63+ except (yaml.YAMLError) as e:
64+ line = column = 1
65+ mark = None
66+ if hasattr(e, 'context_mark') and getattr(e, 'context_mark'):
67+ mark = getattr(e, 'context_mark')
68+ elif hasattr(e, 'problem_mark') and getattr(e, 'problem_mark'):
69+ mark = getattr(e, 'problem_mark')
70+ if mark:
71+ line = mark.line + 1
72+ column = mark.column + 1
73+ errors = (('format-l{line}.c{col}'.format(line=line, col=column),
74+ 'File {0} is not valid yaml. {1}'.format(
75+ config_path, str(e))),)
76+ error = SchemaValidationError(errors)
77+ if annotate:
78+ print(annotated_cloudconfig_file({}, content, error.schema_errors))
79+ raise error
80 try:
81 validate_cloudconfig_schema(
82 cloudconfig, schema, strict=True)
83@@ -176,7 +202,7 @@ def _schemapath_for_cloudconfig(config, original_content):
84 list_index = 0
85 RE_YAML_INDENT = r'^(\s*)'
86 scopes = []
87- for line_number, line in enumerate(content_lines):
88+ for line_number, line in enumerate(content_lines, 1):
89 indent_depth = len(re.match(RE_YAML_INDENT, line).groups()[0])
90 line = line.strip()
91 if not line or line.startswith('#'):
92@@ -208,8 +234,8 @@ def _schemapath_for_cloudconfig(config, original_content):
93 scopes.append((indent_depth + 2, key + '.0'))
94 for inner_list_index in range(0, len(yaml.safe_load(value))):
95 list_key = key + '.' + str(inner_list_index)
96- schema_line_numbers[list_key] = line_number + 1
97- schema_line_numbers[key] = line_number + 1
98+ schema_line_numbers[list_key] = line_number
99+ schema_line_numbers[key] = line_number
100 return schema_line_numbers
101
102
103@@ -337,9 +363,11 @@ def handle_schema_args(name, args):
104 try:
105 validate_cloudconfig_file(
106 args.config_file, full_schema, args.annotate)
107- except (SchemaValidationError, RuntimeError) as e:
108+ except SchemaValidationError as e:
109 if not args.annotate:
110 error(str(e))
111+ except RuntimeError as e:
112+ error(str(e))
113 else:
114 print("Valid cloud-config file {0}".format(args.config_file))
115 if args.doc:
116diff --git a/cloudinit/util.py b/cloudinit/util.py
117index edfedc7..97cd794 100644
118--- a/cloudinit/util.py
119+++ b/cloudinit/util.py
120@@ -874,8 +874,20 @@ def load_yaml(blob, default=None, allowed=(dict,)):
121 " but got %s instead") %
122 (allowed, type_utils.obj_name(converted)))
123 loaded = converted
124- except (yaml.YAMLError, TypeError, ValueError):
125- logexc(LOG, "Failed loading yaml blob")
126+ except (yaml.YAMLError, TypeError, ValueError) as e:
127+ msg = 'Failed loading yaml blob'
128+ mark = None
129+ if hasattr(e, 'context_mark') and getattr(e, 'context_mark'):
130+ mark = getattr(e, 'context_mark')
131+ elif hasattr(e, 'problem_mark') and getattr(e, 'problem_mark'):
132+ mark = getattr(e, 'problem_mark')
133+ if mark:
134+ msg += (
135+ '. Invalid format at line {line} column {col}: "{err}"'.format(
136+ line=mark.line + 1, col=mark.column + 1, err=e))
137+ else:
138+ msg += '. {err}'.format(err=e)
139+ LOG.warning(msg)
140 return loaded
141
142
143diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py
144index ac41f12..fb266fa 100644
145--- a/tests/unittests/test_handler/test_schema.py
146+++ b/tests/unittests/test_handler/test_schema.py
147@@ -134,22 +134,35 @@ class ValidateCloudConfigFileTest(CiTestCase):
148 with self.assertRaises(SchemaValidationError) as context_mgr:
149 validate_cloudconfig_file(self.config_file, {})
150 self.assertEqual(
151- 'Cloud config schema errors: header: File {0} needs to begin with '
152- '"{1}"'.format(self.config_file, CLOUD_CONFIG_HEADER.decode()),
153+ 'Cloud config schema errors: format-l1.c1: File {0} needs to begin'
154+ ' with "{1}"'.format(
155+ self.config_file, CLOUD_CONFIG_HEADER.decode()),
156 str(context_mgr.exception))
157
158- def test_validateconfig_file_error_on_non_yaml_format(self):
159- """On non-yaml format, validate_cloudconfig_file errors."""
160+ def test_validateconfig_file_error_on_non_yaml_scanner_error(self):
161+ """On non-yaml scan issues, validate_cloudconfig_file errors."""
162+ # Generate a scanner error by providing text on a single line with
163+ # improper indent.
164+ write_file(self.config_file, '#cloud-config\nasdf:\nasdf')
165+ with self.assertRaises(SchemaValidationError) as context_mgr:
166+ validate_cloudconfig_file(self.config_file, {})
167+ self.assertIn(
168+ 'schema errors: format-l3.c1: File {0} is not valid yaml.'.format(
169+ self.config_file),
170+ str(context_mgr.exception))
171+
172+ def test_validateconfig_file_error_on_non_yaml_parser_error(self):
173+ """On non-yaml parser issues, validate_cloudconfig_file errors."""
174 write_file(self.config_file, '#cloud-config\n{}}')
175 with self.assertRaises(SchemaValidationError) as context_mgr:
176 validate_cloudconfig_file(self.config_file, {})
177 self.assertIn(
178- 'schema errors: format: File {0} is not valid yaml.'.format(
179+ 'schema errors: format-l2.c3: File {0} is not valid yaml.'.format(
180 self.config_file),
181 str(context_mgr.exception))
182
183 @skipUnlessJsonSchema()
184- def test_validateconfig_file_sctricty_validates_schema(self):
185+ def test_validateconfig_file_sctrictly_validates_schema(self):
186 """validate_cloudconfig_file raises errors on invalid schema."""
187 schema = {
188 'properties': {'p1': {'type': 'string', 'format': 'hostname'}}}
189@@ -342,6 +355,20 @@ class MainTest(CiTestCase):
190 'Expected either --config-file argument or --doc\n',
191 m_stderr.getvalue())
192
193+ def test_main_absent_config_file(self):
194+ """Main exits non-zero when config file is absent."""
195+ myargs = ['mycmd', '--annotate', '--config-file', 'NOT_A_FILE']
196+ with mock.patch('sys.exit', side_effect=self.sys_exit):
197+ with mock.patch('sys.argv', myargs):
198+ with mock.patch('sys.stderr', new_callable=StringIO) as \
199+ m_stderr:
200+ with self.assertRaises(SystemExit) as context_manager:
201+ main()
202+ self.assertEqual(1, context_manager.exception.code)
203+ self.assertEqual(
204+ 'Configfile NOT_A_FILE does not exist\n',
205+ m_stderr.getvalue())
206+
207 def test_main_prints_docs(self):
208 """When --doc parameter is provided, main generates documentation."""
209 myargs = ['mycmd', '--doc']
210diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py
211index 84941c7..d774f3d 100644
212--- a/tests/unittests/test_util.py
213+++ b/tests/unittests/test_util.py
214@@ -4,6 +4,7 @@ from __future__ import print_function
215
216 import logging
217 import os
218+import re
219 import shutil
220 import stat
221 import tempfile
222@@ -265,26 +266,49 @@ class TestGetCmdline(helpers.TestCase):
223 self.assertEqual("abcd 123", ret)
224
225
226-class TestLoadYaml(helpers.TestCase):
227+class TestLoadYaml(helpers.CiTestCase):
228 mydefault = "7b03a8ebace993d806255121073fed52"
229+ with_logs = True
230
231 def test_simple(self):
232 mydata = {'1': "one", '2': "two"}
233 self.assertEqual(util.load_yaml(yaml.dump(mydata)), mydata)
234
235 def test_nonallowed_returns_default(self):
236+ '''Any unallowed types result in returning default; log the issue.'''
237 # for now, anything not in the allowed list just returns the default.
238 myyaml = yaml.dump({'1': "one"})
239 self.assertEqual(util.load_yaml(blob=myyaml,
240 default=self.mydefault,
241 allowed=(str,)),
242 self.mydefault)
243-
244- def test_bogus_returns_default(self):
245+ regex = re.compile(
246+ r'Yaml load allows \(<(class|type) \'str\'>,\) root types, but'
247+ r' got dict')
248+ self.assertTrue(regex.search(self.logs.getvalue()),
249+ msg='Missing expected yaml load error')
250+
251+ def test_bogus_scan_error_returns_default(self):
252+ '''On Yaml scan error, load_yaml returns the default and logs issue.'''
253 badyaml = "1\n 2:"
254 self.assertEqual(util.load_yaml(blob=badyaml,
255 default=self.mydefault),
256 self.mydefault)
257+ self.assertIn(
258+ 'Failed loading yaml blob. Invalid format at line 2 column 3:'
259+ ' "mapping values are not allowed here',
260+ self.logs.getvalue())
261+
262+ def test_bogus_parse_error_returns_default(self):
263+ '''On Yaml parse error, load_yaml returns default and logs issue.'''
264+ badyaml = "{}}"
265+ self.assertEqual(util.load_yaml(blob=badyaml,
266+ default=self.mydefault),
267+ self.mydefault)
268+ self.assertIn(
269+ 'Failed loading yaml blob. Invalid format at line 1 column 3:'
270+ " \"expected \'<document start>\', but found \'}\'",
271+ self.logs.getvalue())
272
273 def test_unsafe_types(self):
274 # should not load complex types

Subscribers

People subscribed via source and target branches