Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include fails silently and new feature flag functionality #367

Merged
merged 1 commit into from
Jun 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions HACKING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ variable annotations specified in `PEP-526`_ were introduced in Python

in a xenial lxd container with python3-pytest installed.

Feature Flags
-------------

.. automodule:: cloudinit.features
:members:


Ongoing Refactors
=================

Expand Down
33 changes: 33 additions & 0 deletions cloudinit/features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# This file is part of cloud-init. See LICENSE file for license information.
"""
Feature flags are used as a way to easily toggle configuration
**at build time**. They are provided to accommodate feature deprecation and
downstream configuration changes.

Currently used upstream values for feature flags are set in
``cloudinit/features.py``. Overrides to these values (typically via quilt
patch) can be placed
in a file called ``feature_overrides.py`` in the same directory. Any value
set in ``feature_overrides.py`` will override the original value set
in ``features.py``.

Each flag should include a short comment regarding the reason for
the flag and intended lifetime.

Tests are required for new feature flags, and tests must verify
all valid states of a flag, not just the default state.
"""
OddBloke marked this conversation as resolved.
Show resolved Hide resolved

ERROR_ON_USER_DATA_FAILURE = True
"""
If there is a failure in obtaining user data (i.e., #include or
decompress fails), old behavior is to log a warning and proceed.
After the 20.2 release, we instead raise an exception.
This flag can be removed after Focal is no longer supported
"""

try:
# pylint: disable=wildcard-import
from cloudinit.feature_overrides import * # noqa
except ImportError:
pass
60 changes: 60 additions & 0 deletions cloudinit/tests/test_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# This file is part of cloud-init. See LICENSE file for license information.
# pylint: disable=no-member,no-name-in-module
"""
This file is for testing the feature flag functionality itself,
NOT for testing any individual feature flag
"""
import pytest
import sys
from pathlib import Path

import cloudinit


@pytest.yield_fixture()
def create_override(request):
OddBloke marked this conversation as resolved.
Show resolved Hide resolved
"""
Create a feature overrides file and do some module wizardry to make
it seem like we're importing the features file for the first time.

After creating the override file with the values passed by the test,
we need to reload cloudinit.features
to get all of the current features (including the overridden ones).
Once the test is complete, we remove the file we created and set
features and feature_overrides modules to how they were before
the test started
"""
override_path = Path(cloudinit.__file__).parent / 'feature_overrides.py'
if override_path.exists():
raise Exception("feature_overrides.py unexpectedly exists! "
"Remove it to run this test.")
with override_path.open('w') as f:
for key, value in request.param.items():
f.write('{} = {}\n'.format(key, value))

sys.modules.pop('cloudinit.features', None)

yield

override_path.unlink()
OddBloke marked this conversation as resolved.
Show resolved Hide resolved
sys.modules.pop('cloudinit.feature_overrides', None)


class TestFeatures:
OddBloke marked this conversation as resolved.
Show resolved Hide resolved
def test_feature_without_override(self):
from cloudinit.features import ERROR_ON_USER_DATA_FAILURE
assert ERROR_ON_USER_DATA_FAILURE is True

@pytest.mark.parametrize('create_override',
[{'ERROR_ON_USER_DATA_FAILURE': False}],
indirect=True)
def test_feature_with_override(self, create_override):
from cloudinit.features import ERROR_ON_USER_DATA_FAILURE
assert ERROR_ON_USER_DATA_FAILURE is False

@pytest.mark.parametrize('create_override',
[{'SPAM': True}],
indirect=True)
def test_feature_only_in_override(self, create_override):
from cloudinit.features import SPAM
assert SPAM is True
31 changes: 22 additions & 9 deletions cloudinit/user_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from cloudinit import handlers
from cloudinit import log as logging
from cloudinit import features
from cloudinit.url_helper import read_file_or_url, UrlError
from cloudinit import util

Expand Down Expand Up @@ -69,6 +70,13 @@ def _set_filename(msg, filename):
'attachment', filename=str(filename))


def _handle_error(error_message, source_exception=None):
if features.ERROR_ON_USER_DATA_FAILURE:
raise Exception(error_message) from source_exception
else:
LOG.warning(error_message)


class UserDataProcessor(object):
def __init__(self, paths):
self.paths = paths
Expand Down Expand Up @@ -108,9 +116,11 @@ def find_ctype(payload):
ctype_orig = None
was_compressed = True
except util.DecompressionError as e:
LOG.warning("Failed decompressing payload from %s of"
" length %s due to: %s",
ctype_orig, len(payload), e)
error_message = (
"Failed decompressing payload from {} of"
" length {} due to: {}".format(
ctype_orig, len(payload), e))
_handle_error(error_message, e)
continue
OddBloke marked this conversation as resolved.
Show resolved Hide resolved

# Attempt to figure out the payloads content-type
Expand Down Expand Up @@ -231,19 +241,22 @@ def _do_include(self, content, append_msg):
if resp.ok():
content = resp.contents
else:
LOG.warning(("Fetching from %s resulted in"
" a invalid http code of %s"),
include_url, resp.code)
error_message = (
"Fetching from {} resulted in"
" a invalid http code of {}".format(
include_url, resp.code))
_handle_error(error_message)
except UrlError as urle:
message = str(urle)
# Older versions of requests.exceptions.HTTPError may not
# include the errant url. Append it for clarity in logs.
if include_url not in message:
message += ' for url: {0}'.format(include_url)
LOG.warning(message)
_handle_error(message, urle)
except IOError as ioe:
LOG.warning("Fetching from %s resulted in %s",
include_url, ioe)
error_message = "Fetching from {} resulted in {}".format(
include_url, ioe)
_handle_error(error_message, ioe)

if content is not None:
new_msg = convert_string(content)
Expand Down
25 changes: 25 additions & 0 deletions tests/unittests/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,31 @@ def test_include_bad_url(self, mock_sleep):

blob = '#include\n%s\n%s' % (bad_url, included_url)

self.reRoot()
ci = stages.Init()
ci.datasource = FakeDataSource(blob)
ci.fetch()
with self.assertRaises(Exception) as context:
ci.consume_data()
self.assertIn('403', str(context.exception))

with self.assertRaises(FileNotFoundError):
util.load_file(ci.paths.get_ipath("cloud_config"))

@mock.patch('cloudinit.url_helper.time.sleep')
@mock.patch('cloudinit.features.ERROR_ON_USER_DATA_FAILURE', False)
def test_include_bad_url_no_fail(self, mock_sleep):
"""Test #include with a bad URL and failure disabled"""
bad_url = 'http://bad/forbidden'
bad_data = '#cloud-config\nbad: true\n'
httpretty.register_uri(httpretty.GET, bad_url, bad_data, status=403)

included_url = 'http://hostname/path'
included_data = '#cloud-config\nincluded: true\n'
httpretty.register_uri(httpretty.GET, included_url, included_data)

blob = '#include\n%s\n%s' % (bad_url, included_url)

self.reRoot()
ci = stages.Init()
ci.datasource = FakeDataSource(blob)
Expand Down