Skip to content

Commit 393e31a

Browse files
humitosagjohnson
authored andcommitted
Save Docker image hash in RTD environment.json file (#3880)
* 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 * Improve docstring * Handle obsolete cases better * when the file is corrupted or we don't have access, we return that it's OBSOLETE * when there is a new setting that we need to compare and it's not in the JSON file, we return OBSOLETE * Test case for build image in the config but not in the JSON
1 parent 7aa6f4d commit 393e31a

File tree

3 files changed

+166
-33
lines changed

3 files changed

+166
-33
lines changed

readthedocs/doc_builder/environments.py

Lines changed: 14 additions & 5 deletions
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

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,14 @@ def environment_json_path(self):
109109
@property
110110
def is_obsolete(self):
111111
"""
112-
Determine if the Python version of the venv obsolete.
112+
Determine if the environment is obsolete for different reasons.
113113
114114
It checks the the data stored at ``readthedocs-environment.json`` and
115-
compares it against the Python version in the project version to be
116-
built and the Docker image used to create the venv against the one in
117-
the project version config.
115+
compares it with the one to be used. In particular:
116+
117+
* the Python version (e.g. 2.7, 3, 3.6, etc)
118+
* the Docker image name
119+
* the Docker image hash
118120
119121
:returns: ``True`` when it's obsolete and ``False`` otherwise
120122
@@ -129,35 +131,43 @@ def is_obsolete(self):
129131
try:
130132
with open(self.environment_json_path(), 'r') as fpath:
131133
environment_conf = json.load(fpath)
132-
env_python_version = environment_conf['python']['version']
133-
env_build_image = environment_conf['build']['image']
134134
except (IOError, TypeError, KeyError, ValueError):
135-
log.error('Unable to read/parse readthedocs-environment.json file')
136-
return False
137-
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-
135+
log.warning('Unable to read/parse readthedocs-environment.json file')
136+
# We remove the JSON file here to avoid cycling over time with a
137+
# corrupted file.
138+
os.remove(self.environment_json_path())
139+
return True
140+
141+
env_python = environment_conf.get('python', {})
142+
env_build = environment_conf.get('build', {})
143+
144+
# By defaulting non-existent options to ``None`` we force a wipe since
145+
# we don't know how the environment was created
146+
env_python_version = env_python.get('version', None)
147+
env_build_image = env_build.get('image', None)
148+
env_build_hash = env_build.get('hash', None)
149+
150+
build_image = self.config.build_image or DOCKER_IMAGE
141151
# If the user define the Python version just as a major version
142152
# (e.g. ``2`` or ``3``) we won't know exactly which exact version was
143153
# used to create the venv but we can still compare it against the new
144154
# one coming from the project version config.
145155
return any([
146156
env_python_version != self.config.python_full_version,
147157
env_build_image != build_image,
158+
env_build_hash != self.build_env.image_hash,
148159
])
149160

150161
def save_environment_json(self):
151162
"""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-
163+
build_image = self.config.build_image or DOCKER_IMAGE
155164
data = {
156165
'python': {
157166
'version': self.config.python_full_version,
158167
},
159168
'build': {
160169
'image': build_image,
170+
'hash': self.build_env.image_hash,
161171
},
162172
}
163173

readthedocs/rtd_tests/tests/test_doc_building.py

Lines changed: 127 additions & 13 deletions
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,82 @@ 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+
def test_is_obsolete_with_json_missing_build_hash(self):
1038+
config_data = {
1039+
'build': {
1040+
'image': '2.0',
1041+
'hash': 'a1b2c3',
1042+
},
1043+
'python': {
1044+
'version': 2.7,
1045+
},
1046+
}
1047+
yaml_config = create_load(config_data)()[0]
1048+
config = ConfigWrapper(version=self.version, yaml_config=yaml_config)
1049+
1050+
# Set container_image manually
1051+
self.pip.container_image = 'readthedocs/build:2.0'
1052+
self.pip.save()
1053+
1054+
python_env = Virtualenv(
1055+
version=self.version,
1056+
build_env=self.build_env,
1057+
config=config,
1058+
)
1059+
env_json_data = '{"build": {"image": "readthedocs/build:2.0"}, "python": {"version": 2.7}}' # noqa
1060+
with patch('os.path.exists') as exists, patch('readthedocs.doc_builder.python_environments.open', mock_open(read_data=env_json_data)) as _open: # noqa
1061+
exists.return_value = True
1062+
self.assertTrue(python_env.is_obsolete)
1063+
1064+
1065+
@patch(
1066+
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
1067+
PropertyMock(return_value='a1b2c3'),
1068+
)
1069+
class AutoWipeDockerBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
1070+
build_env_class = DockerBuildEnvironment
1071+
1072+
1073+
@pytest.mark.xfail(
1074+
reason='PythonEnvironment needs to be refactored to do not rely on DockerBuildEnvironment',
1075+
)
1076+
@patch(
1077+
'readthedocs.doc_builder.environments.DockerBuildEnvironment.image_hash',
1078+
PropertyMock(return_value='a1b2c3'),
1079+
)
1080+
class AutoWipeLocalBuildEnvironmentTest(AutoWipeEnvironmentBase, TestCase):
1081+
build_env_class = LocalBuildEnvironment

0 commit comments

Comments
 (0)