Skip to content

Save docker image hash to consider when auto wiping the environment #3793

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

Merged
merged 8 commits into from
Mar 23, 2018
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
19 changes: 14 additions & 5 deletions readthedocs/doc_builder/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,8 @@ def __init__(self, *args, **kwargs):
)
if self.config and self.config.build_image:
self.container_image = self.config.build_image
if self.project.container_image:
self.container_image = self.project.container_image
if self.project.container_mem_limit:
self.container_mem_limit = self.project.container_mem_limit
if self.project.container_time_limit:
Expand Down Expand Up @@ -782,6 +784,13 @@ def get_container_host_config(self):
})
return create_host_config(binds=binds)

@property
def image_hash(self):
"""Return the hash of the Docker image."""
client = self.get_client()
image_metadata = client.inspect_image(self.container_image)
return image_metadata.get('Id')

@property
def container_id(self):
"""Return id of container if it is valid."""
Expand Down Expand Up @@ -824,13 +833,13 @@ def update_build_from_container_state(self):
def create_container(self):
"""Create docker container."""
client = self.get_client()
image = self.container_image
if self.project.container_image:
image = self.project.container_image
try:
log.info('Creating Docker container: image=%s', image)
log.info(
'Creating Docker container: image=%s',
self.container_image,
)
self.container = client.create_container(
image=image,
image=self.container_image,
command=('/bin/sh -c "sleep {time}; exit {exit}"'
.format(time=self.container_time_limit,
exit=DOCKER_TIMEOUT_EXIT_CODE)),
Expand Down
11 changes: 5 additions & 6 deletions readthedocs/doc_builder/python_environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,33 +131,32 @@ def is_obsolete(self):
environment_conf = json.load(fpath)
env_python_version = environment_conf['python']['version']
env_build_image = environment_conf['build']['image']
env_build_hash = environment_conf['build']['hash']
except (IOError, TypeError, KeyError, ValueError):
log.error('Unable to read/parse readthedocs-environment.json file')
return False

# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa

build_image = self.config.build_image or DOCKER_IMAGE
# If the user define the Python version just as a major version
# (e.g. ``2`` or ``3``) we won't know exactly which exact version was
# used to create the venv but we can still compare it against the new
# one coming from the project version config.
return any([
env_python_version != self.config.python_full_version,
env_build_image != build_image,
env_build_hash != self.build_env.image_hash,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will force wipe all existing builds, but perhaps that is a good thing. We'll have explicit metadata after the next build of all of these projects.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm...

This won't force the wipe because it will fail in the L134 (https://github.com/rtfd/readthedocs.org/pull/3793/files/05d74aec013e1b211798ba02e91636d331794a1d#diff-31f8391d8643c47c6caedf53930c0b7eR134) when trying to access the build.hash that will produce a KeyError exception and we will return False.

Then, the current build with the old environment will be used but we will be saving the current new attributes and... we have a bug here, right?

])

def save_environment_json(self):
"""Save on disk Python and build image versions used to create the venv."""
# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa

build_image = self.config.build_image or DOCKER_IMAGE
data = {
'python': {
'version': self.config.python_full_version,
},
'build': {
'image': build_image,
'hash': self.build_env.image_hash,
},
}

Expand Down
114 changes: 100 additions & 14 deletions readthedocs/rtd_tests/tests/test_doc_building.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
absolute_import, division, print_function, unicode_literals)

import os.path
import json
import re
import tempfile
import uuid
from builtins import str

Expand Down Expand Up @@ -837,14 +839,54 @@ def test_command_oom_kill(self):
u'Command killed due to excessive memory consumption\n')




class TestAutoWipeEnvironment(TestCase):
class AutoWipeEnvironmentBase(object):
fixtures = ['test_data']
build_env_class = None

def setUp(self):
self.pip = Project.objects.get(slug='pip')
self.version = self.pip.versions.get(slug='0.8')
self.build_env = self.build_env_class(
project=self.pip,
version=self.version,
build={'id': DUMMY_BUILD_ID},
)

def test_save_environment_json(self):
config_data = {
'build': {
'image': '2.0',
},
'python': {
'version': 2.7,
},
}
yaml_config = create_load(config_data)()[0]
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)

python_env = Virtualenv(
version=self.version,
build_env=self.build_env,
config=config,
)

with patch(
'readthedocs.doc_builder.python_environments.PythonEnvironment.environment_json_path',
return_value=tempfile.mktemp(suffix='envjson'),
):
python_env.save_environment_json()
json_data = json.load(open(python_env.environment_json_path()))

expected_data = {
'build': {
'image': 'readthedocs/build:2.0',
'hash': 'a1b2c3',
},
'python': {
'version': 2.7,
},
}
self.assertDictEqual(json_data, expected_data)

def test_is_obsolete_without_env_json_file(self):
yaml_config = create_load()()[0]
Expand All @@ -854,7 +896,7 @@ def test_is_obsolete_without_env_json_file(self):
exists.return_value = False
python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)

Expand All @@ -868,7 +910,7 @@ def test_is_obsolete_with_invalid_env_json_file(self):
exists.return_value = True
python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)

Expand All @@ -888,15 +930,14 @@ def test_is_obsolete_with_json_different_python_version(self):

python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 3.5}}' # noqa
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
exists.return_value = True
self.assertTrue(python_env.is_obsolete)

@pytest.mark.xfail(reason='build.image is not being considered yet')
def test_is_obsolete_with_json_different_build_image(self):
config_data = {
'build': {
Expand All @@ -911,10 +952,10 @@ def test_is_obsolete_with_json_different_build_image(self):

python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 2.7}}' # noqa
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
exists.return_value = True
self.assertTrue(python_env.is_obsolete)
Expand All @@ -937,10 +978,10 @@ def test_is_obsolete_with_project_different_build_image(self):

python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 2.7}}' # noqa
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
exists.return_value = True
self.assertTrue(python_env.is_obsolete)
Expand All @@ -959,10 +1000,55 @@ def test_is_obsolete_with_json_same_data_as_version(self):

python_env = Virtualenv(
version=self.version,
build_env=None,
build_env=self.build_env,
config=config,
)
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 3.5}}' # noqa
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
exists.return_value = True
self.assertFalse(python_env.is_obsolete)

def test_is_obsolete_with_json_different_build_hash(self):
config_data = {
'build': {
'image': '2.0',
},
'python': {
'version': 2.7,
},
}
yaml_config = create_load(config_data)()[0]
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)

# Set container_image manually
self.pip.container_image = 'readthedocs/build:2.0'
self.pip.save()

python_env = Virtualenv(
version=self.version,
build_env=self.build_env,
config=config,
)
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "foo"}, "python": {"version": 2.7}}' # noqa
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
exists.return_value = True
self.assertTrue(python_env.is_obsolete)


@patch(
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
PropertyMock(return_value='a1b2c3'),
)
class AutoWipeDockerBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
build_env_class = DockerBuildEnvironment


@pytest.mark.xfail(
reason='PythonEnvironment needs to be refactored to do not rely on DockerBuildEnvironment',
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be good to lay out in a new ticket

@patch(
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
PropertyMock(return_value='a1b2c3'),
)
class AutoWipeLocalBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
build_env_class = LocalBuildEnvironment