Skip to content

Commit 7ac7dfc

Browse files
humitosagjohnson
authored andcommitted
Save docker image hash to consider when auto wiping the environment (#3793)
* Remove obsolete code Now, we can access `self.config.build_image` directly. * Move container_image selection to the init At initialization time we have the project and we already know if the project has the build image override so we can decide at that point and save it as a instance attribute. Then we can use this values from other places inside the same class. * Save Docker Image hash in readthedocs-environment.json The hash is used to know if the environment is obsolete and auto-wipe it if necessary. * Simplify the class naming * Save the image hash in the json file * Lint * Remove invalid properties from YAML config in tests * Add test for save_environment_json
1 parent 233febf commit 7ac7dfc

File tree

3 files changed

+119
-24
lines changed

3 files changed

+119
-24
lines changed

readthedocs/doc_builder/environments.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,8 @@ def __init__(self, *args, **kwargs):
625625
)
626626
if self.config and self.config.build_image:
627627
self.container_image = self.config.build_image
628+
if self.project.container_image:
629+
self.container_image = self.project.container_image
628630
if self.project.container_mem_limit:
629631
self.container_mem_limit = self.project.container_mem_limit
630632
if self.project.container_time_limit:
@@ -786,6 +788,13 @@ def get_container_host_config(self):
786788
mem_limit=self.container_mem_limit,
787789
)
788790

791+
@property
792+
def image_hash(self):
793+
"""Return the hash of the Docker image."""
794+
client = self.get_client()
795+
image_metadata = client.inspect_image(self.container_image)
796+
return image_metadata.get('Id')
797+
789798
@property
790799
def container_id(self):
791800
"""Return id of container if it is valid."""
@@ -828,13 +837,13 @@ def update_build_from_container_state(self):
828837
def create_container(self):
829838
"""Create docker container."""
830839
client = self.get_client()
831-
image = self.container_image
832-
if self.project.container_image:
833-
image = self.project.container_image
834840
try:
835-
log.info('Creating Docker container: image=%s', image)
841+
log.info(
842+
'Creating Docker container: image=%s',
843+
self.container_image,
844+
)
836845
self.container = client.create_container(
837-
image=image,
846+
image=self.container_image,
838847
command=('/bin/sh -c "sleep {time}; exit {exit}"'
839848
.format(time=self.container_time_limit,
840849
exit=DOCKER_TIMEOUT_EXIT_CODE)),

readthedocs/doc_builder/python_environments.py

+5-6
Original file line numberDiff line numberDiff line change
@@ -131,33 +131,32 @@ def is_obsolete(self):
131131
environment_conf = json.load(fpath)
132132
env_python_version = environment_conf['python']['version']
133133
env_build_image = environment_conf['build']['image']
134+
env_build_hash = environment_conf['build']['hash']
134135
except (IOError, TypeError, KeyError, ValueError):
135136
log.error('Unable to read/parse readthedocs-environment.json file')
136137
return False
137138

138-
# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
139-
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa
140-
139+
build_image = self.config.build_image or DOCKER_IMAGE
141140
# If the user define the Python version just as a major version
142141
# (e.g. ``2`` or ``3``) we won't know exactly which exact version was
143142
# used to create the venv but we can still compare it against the new
144143
# one coming from the project version config.
145144
return any([
146145
env_python_version != self.config.python_full_version,
147146
env_build_image != build_image,
147+
env_build_hash != self.build_env.image_hash,
148148
])
149149

150150
def save_environment_json(self):
151151
"""Save on disk Python and build image versions used to create the venv."""
152-
# TODO: remove getattr when https://github.com/rtfd/readthedocs.org/pull/3339 got merged
153-
build_image = getattr(self.config, 'build_image', self.version.project.container_image) or DOCKER_IMAGE # noqa
154-
152+
build_image = self.config.build_image or DOCKER_IMAGE
155153
data = {
156154
'python': {
157155
'version': self.config.python_full_version,
158156
},
159157
'build': {
160158
'image': build_image,
159+
'hash': self.build_env.image_hash,
161160
},
162161
}
163162

readthedocs/rtd_tests/tests/test_doc_building.py

+100-13
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
absolute_import, division, print_function, unicode_literals)
1010

1111
import os.path
12+
import json
1213
import re
14+
import tempfile
1315
import uuid
1416
from builtins import str
1517

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

839841

840-
841-
842-
class TestAutoWipeEnvironment(TestCase):
842+
class AutoWipeEnvironmentBase(object):
843843
fixtures = ['test_data']
844+
build_env_class = None
844845

845846
def setUp(self):
846847
self.pip = Project.objects.get(slug='pip')
847848
self.version = self.pip.versions.get(slug='0.8')
849+
self.build_env = self.build_env_class(
850+
project=self.pip,
851+
version=self.version,
852+
build={'id': DUMMY_BUILD_ID},
853+
)
854+
855+
def test_save_environment_json(self):
856+
config_data = {
857+
'build': {
858+
'image': '2.0',
859+
},
860+
'python': {
861+
'version': 2.7,
862+
},
863+
}
864+
yaml_config = create_load(config_data)()[0]
865+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
866+
867+
python_env = Virtualenv(
868+
version=self.version,
869+
build_env=self.build_env,
870+
config=config,
871+
)
872+
873+
with patch(
874+
'readthedocs.doc_builder.python_environments.PythonEnvironment.environment_json_path',
875+
return_value=tempfile.mktemp(suffix='envjson'),
876+
):
877+
python_env.save_environment_json()
878+
json_data = json.load(open(python_env.environment_json_path()))
879+
880+
expected_data = {
881+
'build': {
882+
'image': 'readthedocs/build:2.0',
883+
'hash': 'a1b2c3',
884+
},
885+
'python': {
886+
'version': 2.7,
887+
},
888+
}
889+
self.assertDictEqual(json_data, expected_data)
848890

849891
def test_is_obsolete_without_env_json_file(self):
850892
yaml_config = create_load()()[0]
@@ -854,7 +896,7 @@ def test_is_obsolete_without_env_json_file(self):
854896
exists.return_value = False
855897
python_env = Virtualenv(
856898
version=self.version,
857-
build_env=None,
899+
build_env=self.build_env,
858900
config=config,
859901
)
860902

@@ -868,7 +910,7 @@ def test_is_obsolete_with_invalid_env_json_file(self):
868910
exists.return_value = True
869911
python_env = Virtualenv(
870912
version=self.version,
871-
build_env=None,
913+
build_env=self.build_env,
872914
config=config,
873915
)
874916

@@ -888,10 +930,10 @@ def test_is_obsolete_with_json_different_python_version(self):
888930

889931
python_env = Virtualenv(
890932
version=self.version,
891-
build_env=None,
933+
build_env=self.build_env,
892934
config=config,
893935
)
894-
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
936+
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 3.5}}' # noqa
895937
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
896938
exists.return_value = True
897939
self.assertTrue(python_env.is_obsolete)
@@ -910,10 +952,10 @@ def test_is_obsolete_with_json_different_build_image(self):
910952

911953
python_env = Virtualenv(
912954
version=self.version,
913-
build_env=None,
955+
build_env=self.build_env,
914956
config=config,
915957
)
916-
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
958+
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 2.7}}' # noqa
917959
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
918960
exists.return_value = True
919961
self.assertTrue(python_env.is_obsolete)
@@ -936,10 +978,10 @@ def test_is_obsolete_with_project_different_build_image(self):
936978

937979
python_env = Virtualenv(
938980
version=self.version,
939-
build_env=None,
981+
build_env=self.build_env,
940982
config=config,
941983
)
942-
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}'
984+
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 2.7}}' # noqa
943985
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
944986
exists.return_value = True
945987
self.assertTrue(python_env.is_obsolete)
@@ -958,10 +1000,55 @@ def test_is_obsolete_with_json_same_data_as_version(self):
9581000

9591001
python_env = Virtualenv(
9601002
version=self.version,
961-
build_env=None,
1003+
build_env=self.build_env,
9621004
config=config,
9631005
)
964-
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 3.5}}'
1006+
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "a1b2c3"}, "python": {"version": 3.5}}' # noqa
9651007
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
9661008
exists.return_value = True
9671009
self.assertFalse(python_env.is_obsolete)
1010+
1011+
def test_is_obsolete_with_json_different_build_hash(self):
1012+
config_data = {
1013+
'build': {
1014+
'image': '2.0',
1015+
},
1016+
'python': {
1017+
'version': 2.7,
1018+
},
1019+
}
1020+
yaml_config = create_load(config_data)()[0]
1021+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
1022+
1023+
# Set container_image manually
1024+
self.pip.container_image = 'readthedocs/build:2.0'
1025+
self.pip.save()
1026+
1027+
python_env = Virtualenv(
1028+
version=self.version,
1029+
build_env=self.build_env,
1030+
config=config,
1031+
)
1032+
env_json_data = '{"build": {"image": "readthedocs/build:2.0", "hash": "foo"}, "python": {"version": 2.7}}' # noqa
1033+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
1034+
exists.return_value = True
1035+
self.assertTrue(python_env.is_obsolete)
1036+
1037+
1038+
@patch(
1039+
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
1040+
PropertyMock(return_value='a1b2c3'),
1041+
)
1042+
class AutoWipeDockerBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
1043+
build_env_class = DockerBuildEnvironment
1044+
1045+
1046+
@pytest.mark.xfail(
1047+
reason='PythonEnvironment needs to be refactored to do not rely on DockerBuildEnvironment',
1048+
)
1049+
@patch(
1050+
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
1051+
PropertyMock(return_value='a1b2c3'),
1052+
)
1053+
class AutoWipeLocalBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
1054+
build_env_class = LocalBuildEnvironment

0 commit comments

Comments
 (0)