Skip to content

Commit

Permalink
Don't throw exceptions for empty cloud config (#1130)
Browse files Browse the repository at this point in the history
Warn during boot when an empty config is provided. Likewise,
`cloud-init devel schema --annotate` should not throw exception, return
something meaningful instead.
  • Loading branch information
holmanb authored Jan 6, 2022
1 parent fef532d commit 3e64acd
Show file tree
Hide file tree
Showing 3 changed files with 67 additions and 7 deletions.
27 changes: 20 additions & 7 deletions cloudinit/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def validate_cloudconfig_schema(
@raises: SchemaValidationError when provided config does not validate
against the provided schema.
@raises: RuntimeError when provided config sourced from YAML is not a dict.
"""
try:
(cloudinitValidator, FormatChecker) = get_jsonschema_validator()
Expand Down Expand Up @@ -217,13 +218,21 @@ def annotated_cloudconfig_file(cloudconfig, original_content, schema_errors):
if not schema_errors:
return original_content
schemapaths = {}
errors_by_line = defaultdict(list)
error_footer = []
error_header = "# Errors: -------------\n{0}\n\n"
annotated_content = []
lines = original_content.decode().split("\n")
if not isinstance(cloudconfig, dict):
# Return a meaningful message on empty cloud-config
return "\n".join(
lines
+ [error_header.format("# E1: Cloud-config is not a YAML dict.")]
)
if cloudconfig:
schemapaths = _schemapath_for_cloudconfig(
cloudconfig, original_content
)
errors_by_line = defaultdict(list)
error_footer = []
annotated_content = []
for path, msg in schema_errors:
match = re.match(r"format-l(?P<line>\d+)\.c(?P<col>\d+).*", path)
if match:
Expand All @@ -236,7 +245,6 @@ def annotated_cloudconfig_file(cloudconfig, original_content, schema_errors):
msg = "Line {line} column {col}: {msg}".format(
line=line, col=col, msg=msg
)
lines = original_content.decode().split("\n")
error_index = 1
for line_number, line in enumerate(lines, 1):
errors = errors_by_line[line_number]
Expand All @@ -247,11 +255,10 @@ def annotated_cloudconfig_file(cloudconfig, original_content, schema_errors):
error_footer.append("# E{0}: {1}".format(error_index, error))
error_index += 1
annotated_content.append(line + "\t\t# " + ",".join(error_label))

else:
annotated_content.append(line)
annotated_content.append(
"# Errors: -------------\n{0}\n\n".format("\n".join(error_footer))
)
annotated_content.append(error_header.format("\n".join(error_footer)))
return "\n".join(annotated_content)


Expand Down Expand Up @@ -318,6 +325,10 @@ def validate_cloudconfig_file(config_path, schema, annotate=False):
if annotate:
print(annotated_cloudconfig_file({}, content, error.schema_errors))
raise error from e
if not isinstance(cloudconfig, dict):
# Return a meaningful message on empty cloud-config
if not annotate:
raise RuntimeError("Cloud-config is not a YAML dict.")
try:
validate_cloudconfig_schema(cloudconfig, schema, strict=True)
except SchemaValidationError as e:
Expand Down Expand Up @@ -662,6 +673,8 @@ def handle_schema_args(name, args):
exclusive_args = [args.config_file, args.docs, args.system]
if len([arg for arg in exclusive_args if arg]) != 1:
error("Expected one of --config-file, --system or --docs arguments")
if args.annotate and args.docs:
error("Invalid flag combination. Cannot use --annotate with --docs")
full_schema = get_schema()
if args.config_file or args.system:
try:
Expand Down
9 changes: 9 additions & 0 deletions cloudinit/handlers/cloud_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ def _extract_mergers(self, payload, headers):
# or the merge type from the headers or default to our own set
# if neither exists (or is empty) from the later.
payload_yaml = util.load_yaml(payload)
if payload_yaml is None:
raise ValueError("empty cloud config")

mergers_yaml = mergers.dict_extract_mergers(payload_yaml)
mergers_header = mergers.string_extract_mergers(merge_header_headers)
all_mergers = []
Expand Down Expand Up @@ -139,6 +142,12 @@ def handle_part(self, data, ctype, filename, payload, frequency, headers):
for i in ("\n", "\r", "\t"):
filename = filename.replace(i, " ")
self.file_names.append(filename.strip())
except ValueError as err:
LOG.warning(
"Failed at merging in cloud config part from %s: %s",
filename,
err,
)
except Exception:
util.logexc(
LOG, "Failed at merging in cloud config part from %s", filename
Expand Down
38 changes: 38 additions & 0 deletions tests/unittests/config/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,31 @@ def test_annotated_cloudconfig_file_no_schema_errors(self):
content, annotated_cloudconfig_file({}, content, schema_errors=[])
)

def test_annotated_cloudconfig_file_with_non_dict_cloud_config(self):
"""Error when empty non-dict cloud-config is provided.
OurJSON validation when user-data is None type generates a bunch
schema validation errors of the format:
('', "None is not of type 'object'"). Ignore those symptoms and
report the general problem instead.
"""
content = b"\n\n\n"
expected = "\n".join(
[
content.decode(),
"# Errors: -------------",
"# E1: Cloud-config is not a YAML dict.\n\n",
]
)
self.assertEqual(
expected,
annotated_cloudconfig_file(
None,
content,
schema_errors=[("", "None is not of type 'object'")],
),
)

def test_annotated_cloudconfig_file_schema_annotates_and_adds_footer(self):
"""With schema_errors, error lines are annotated and a footer added."""
content = dedent(
Expand Down Expand Up @@ -658,6 +683,19 @@ def test_main_absent_config_file(self, capsys):
_out, err = capsys.readouterr()
assert "Error:\nConfigfile NOT_A_FILE does not exist\n" == err

def test_main_invalid_flag_combo(self, capsys):
"""Main exits non-zero when invalid flag combo used."""
myargs = ["mycmd", "--annotate", "--docs", "DOES_NOT_MATTER"]
with mock.patch("sys.argv", myargs):
with pytest.raises(SystemExit) as context_manager:
main()
assert 1 == context_manager.value.code
_, err = capsys.readouterr()
assert (
"Error:\nInvalid flag combination. "
"Cannot use --annotate with --docs\n" == err
)

def test_main_prints_docs(self, capsys):
"""When --docs parameter is provided, main generates documentation."""
myargs = ["mycmd", "--docs", "all"]
Expand Down

0 comments on commit 3e64acd

Please sign in to comment.