-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
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
Build: allow to install packages with apt #8065
Changes from 1 commit
2435a4a
d355827
f6116fc
59768af
45ab78f
b443949
e5923a1
2773710
5f8874b
ecd0183
1d330ea
f735a2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -64,6 +64,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
SUBMODULES_INVALID = 'submodules-invalid' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
INVALID_KEYS_COMBINATION = 'invalid-keys-combination' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
INVALID_KEY = 'invalid-key' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
INVALID_NAME = 'invalid-name' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
LATEST_CONFIGURATION_VERSION = 2 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -124,7 +125,9 @@ def _get_display_key(self): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Checks for patterns similar to `python.install.0.requirements` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# if matched change to `python.install[0].requirements` using backreference. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return re.sub( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
r'^(python\.install)(\.)(\d+)(\.\w+)$', r'\1[\3]\4', self.key | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
r'^([a-zA-Z_.-]+)\.(\d+)([a-zA-Z_.-]*)$', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
r'\1[\2]\3', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.key | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stsewd marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -622,7 +625,10 @@ def conda(self): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def build(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"""The docker image used by the builders.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return Build(**self._config['build']) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return Build( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
apt_packages=[], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stsewd marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
**self._config['build'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def doctype(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -745,12 +751,60 @@ def validate_build(self): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Allow to override specific project | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
config_image = self.defaults.get('build_image') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if config_image: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
build['image'] = config_image | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Allow to override specific project | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
config_image = self.defaults.get('build_image') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if config_image: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
build['image'] = config_image | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with self.catch_validation_error('build.apt_packages'): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
raw_packages = self._raw_config.get('build', {}).get('apt_packages', []) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
validate_list(raw_packages) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Transform to a dict, so is easy to validate individual entries. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._raw_config.setdefault('build', {})['apt_packages'] = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
list_to_dict(raw_packages) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
build['apt_packages'] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.validate_apt_package(index) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
for index in range(len(raw_packages)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if not raw_packages: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.pop_config('build.apt_packages') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return build | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def validate_apt_package(self, index): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like we probably can be smarter about this. Can we not call apt after a
We should still raise a validation error here, but I'd like to see additional restrictions for this built in. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point Eric. Also, we need to take care of executing commands in the same line as well. Like,
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe python's subprocess command will handle this, but we should definitely be sure and have tests for it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we use subprocess, but the Docker client API ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't use subprocess, we escape special chars in readthedocs.org/readthedocs/doc_builder/environments.py Lines 335 to 367 in 2435a4a
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have you tested defining packages in a malicious way? (e.g. trying to executed forbidden things). From this docstring it only happen under certain circumstances. Are we sure we are always calling this in that way? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Validate the package name to avoid injections of extra options. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Packages names can be a regex pattern. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
We just validate that they aren't interpreted as an option or file. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
key = f'build.apt_packages.{index}' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
package = self.pop_config(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with self.catch_validation_error(key): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
validate_string(package) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
package = package.strip() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
invalid_starts = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Don't allow to inject extra options. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'-', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'\\', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Don't allow to install from a path. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
stsewd marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'/', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'.', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
for start in invalid_starts: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I left this bc feels like it gives a nice error for usual mistakes, but isn't needed as the regex below already validates this. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if package.startswith(start): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
key=key, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
message=( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
'Invalid package name. ' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f'Package can\'t start with {start}', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
code=INVALID_NAME, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return package | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def validate_python(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Validates the python key. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -794,7 +794,7 @@ def run_build(self, record): | |||||||
environment=self.build_env, | ||||||||
) | ||||||||
with self.project.repo_nonblockinglock(version=self.version): | ||||||||
self.setup_python_environment() | ||||||||
self.setup_build() | ||||||||
|
||||||||
# TODO the build object should have an idea of these states, | ||||||||
# extend the model to include an idea of these outcomes | ||||||||
|
@@ -1152,6 +1152,10 @@ def update_app_instances( | |||||||
search_ignore=self.config.search.ignore, | ||||||||
) | ||||||||
|
||||||||
def setup_build(self): | ||||||||
self.install_system_dependencies() | ||||||||
self.setup_python_environment() | ||||||||
|
||||||||
def setup_python_environment(self): | ||||||||
""" | ||||||||
Build the virtualenv and install the project into it. | ||||||||
|
@@ -1177,6 +1181,18 @@ def setup_python_environment(self): | |||||||
if self.project.has_feature(Feature.LIST_PACKAGES_INSTALLED_ENV): | ||||||||
self.python_env.list_packages_installed() | ||||||||
|
||||||||
def install_system_dependencies(self): | ||||||||
stsewd marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
packages = self.config.build.apt_packages | ||||||||
if packages: | ||||||||
self.build_env.run( | ||||||||
'apt-get', 'update', '-y', '-q', | ||||||||
user=settings.RTD_BUILD_SUPER_USER, | ||||||||
) | ||||||||
self.build_env.run( | ||||||||
'apt-get', 'install', '-y', '-q', *packages, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should always use the long-version of arguments in code, to make it clearer what it does. Also add in the
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason we want this to be quiet? I know the output is sort of long but it might help folks debug their own problems. I tested it with I looked at what CircleCI does and they basically give users free reign to run arbitrary commands including There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||
user=settings.RTD_BUILD_SUPER_USER, | ||||||||
) | ||||||||
|
||||||||
def build_docs(self): | ||||||||
""" | ||||||||
Wrapper to all build functions. | ||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,7 @@ | |
import shutil | ||
from os.path import exists | ||
from tempfile import mkdtemp | ||
from unittest import mock | ||
from unittest.mock import MagicMock, patch | ||
|
||
from allauth.socialaccount.models import SocialAccount | ||
|
@@ -17,7 +18,11 @@ | |
LATEST, | ||
) | ||
from readthedocs.builds.models import Build, Version | ||
from readthedocs.doc_builder.environments import LocalBuildEnvironment | ||
from readthedocs.config.config import BuildConfigV2 | ||
from readthedocs.doc_builder.environments import ( | ||
BuildEnvironment, | ||
LocalBuildEnvironment, | ||
) | ||
from readthedocs.doc_builder.exceptions import VersionLockedError | ||
from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation | ||
from readthedocs.projects import tasks | ||
|
@@ -469,3 +474,67 @@ def test_send_build_status_no_remote_repo_or_social_account_gitlab(self, send_bu | |
|
||
send_build_status.assert_not_called() | ||
self.assertEqual(Message.objects.filter(user=self.eric).count(), 1) | ||
|
||
@patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_python_environment', new=MagicMock) | ||
@patch('readthedocs.projects.tasks.UpdateDocsTaskStep.build_docs', new=MagicMock) | ||
@patch('readthedocs.doc_builder.environments.BuildEnvironment.update_build', new=MagicMock) | ||
@patch('readthedocs.projects.tasks.UpdateDocsTaskStep.setup_vcs', new=MagicMock) | ||
@patch.object(BuildEnvironment, 'run') | ||
@patch('readthedocs.doc_builder.config.load_config') | ||
def test_install_apt_packages(self, load_config, run): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like a lot of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was able to remove one patch, but I think a cleaner way would require refactor the build task so we can mock |
||
config = BuildConfigV2( | ||
{}, | ||
{ | ||
'version': 2, | ||
'build': { | ||
'apt_packages': [ | ||
'clangd', | ||
'cmatrix', | ||
], | ||
}, | ||
}, | ||
source_file='readthedocs.yml', | ||
) | ||
config.validate() | ||
load_config.return_value = config | ||
|
||
version = self.project.versions.first() | ||
build = get( | ||
Build, | ||
project=self.project, | ||
version=version, | ||
) | ||
with mock_api(self.repo): | ||
result = tasks.update_docs_task.delay( | ||
version.pk, | ||
build_pk=build.pk, | ||
record=False, | ||
intersphinx=False, | ||
) | ||
self.assertTrue(result.successful()) | ||
|
||
self.assertEqual(run.call_count, 2) | ||
apt_update = run.call_args_list[0] | ||
apt_install = run.call_args_list[1] | ||
self.assertEqual( | ||
apt_update, | ||
mock.call( | ||
'apt-get', | ||
'update', | ||
'-y', | ||
'-q', | ||
user='root:root', | ||
) | ||
) | ||
self.assertEqual( | ||
apt_install, | ||
mock.call( | ||
'apt-get', | ||
'install', | ||
'-y', | ||
'-q', | ||
'clangd', | ||
'cmatrix', | ||
user='root:root', | ||
) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this will spam the TOC too much -- seems fine tho?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was between 2 or 3, but wanted to have
build.apt_images
andsphing.builder
visible instead of just havingsphinx
andbuild
, but no strong opinion.