-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
environments.py
951 lines (825 loc) · 35 KB
/
environments.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
# -*- coding: utf-8 -*-
"""Documentation Builder Environments."""
from __future__ import (
absolute_import, division, print_function, unicode_literals)
import logging
import os
import re
import socket
import subprocess
import sys
import traceback
from datetime import datetime
import six
from builtins import object, str
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from docker import APIClient
from docker.errors import APIError as DockerAPIError
from docker.errors import DockerException
from requests.exceptions import ConnectionError
from slumber.exceptions import HttpClientError
from readthedocs.builds.constants import BUILD_STATE_FINISHED
from readthedocs.builds.models import BuildCommandResultMixin
from readthedocs.core.utils import slugify
from readthedocs.projects.constants import LOG_TEMPLATE
from readthedocs.restapi.client import api as api_v2
from .constants import (
DOCKER_HOSTNAME_MAX_LEN, DOCKER_IMAGE, DOCKER_LIMITS, DOCKER_OOM_EXIT_CODE,
DOCKER_SOCKET, DOCKER_TIMEOUT_EXIT_CODE, DOCKER_VERSION,
MKDOCS_TEMPLATE_DIR, SPHINX_TEMPLATE_DIR)
from .exceptions import (
BuildEnvironmentCreationFailed, BuildEnvironmentError,
BuildEnvironmentException, BuildEnvironmentWarning, BuildTimeoutError,
ProjectBuildsSkippedError, VersionLockedError, YAMLParseError)
log = logging.getLogger(__name__)
__all__ = (
'api_v2',
'BuildCommand',
'DockerBuildCommand',
'LocalEnvironment',
'LocalBuildEnvironment',
'DockerBuildEnvironment',
)
class BuildCommand(BuildCommandResultMixin):
"""
Wrap command execution for execution in build environments.
This wraps subprocess commands with some logic to handle exceptions,
logging, and setting up the env for the build command.
This acts a mapping of sorts to the API representation of the
:py:class:`readthedocs.builds.models.BuildCommandResult` model.
:param command: string or array of command parameters
:param cwd: current working path for the command
:param shell: execute command in shell, default=False
:param environment: environment variables to add to environment
:type environment: dict
:param combine_output: combine stdout/stderr, default=True
:param input_data: data to pass in on stdin
:type input_data: str
:param build_env: build environment to use to execute commands
:param bin_path: binary path to add to PATH resolution
:param description: a more grokable description of the command being run
"""
def __init__(self, command, cwd=None, shell=False, environment=None,
combine_output=True, input_data=None, build_env=None,
bin_path=None, description=None, record_as_success=False):
self.command = command
self.shell = shell
if cwd is None:
cwd = os.getcwd()
self.cwd = cwd
self.environment = os.environ.copy()
if environment is not None:
assert 'PATH' not in environment, "PATH can't be set"
self.environment.update(environment)
self.combine_output = combine_output
self.input_data = input_data
self.build_env = build_env
self.output = None
self.error = None
self.start_time = None
self.end_time = None
self.bin_path = bin_path
self.description = ''
if description is not None:
self.description = description
self.record_as_success = record_as_success
self.exit_code = None
def __str__(self):
# TODO do we want to expose the full command here?
output = u''
if self.output is not None:
output = self.output.encode('utf-8')
return '\n'.join([self.get_command(), output])
def run(self):
"""
Set up subprocess and execute command.
:param cmd_input: input to pass to command in STDIN
:type cmd_input: str
:param combine_output: combine STDERR into STDOUT
"""
log.info("Running: '%s' [%s]", self.get_command(), self.cwd)
self.start_time = datetime.utcnow()
stdout = subprocess.PIPE
stderr = subprocess.PIPE
stdin = None
if self.input_data is not None:
stdin = subprocess.PIPE
if self.combine_output:
stderr = subprocess.STDOUT
environment = {}
environment.update(self.environment)
environment['READTHEDOCS'] = 'True'
if self.build_env is not None:
environment['READTHEDOCS_VERSION'] = self.build_env.version.slug
environment['READTHEDOCS_PROJECT'] = self.build_env.project.slug
if 'DJANGO_SETTINGS_MODULE' in environment:
del environment['DJANGO_SETTINGS_MODULE']
if 'PYTHONPATH' in environment:
del environment['PYTHONPATH']
if self.bin_path is not None:
env_paths = environment.get('PATH', '').split(':')
env_paths.insert(0, self.bin_path)
environment['PATH'] = ':'.join(env_paths)
try:
proc = subprocess.Popen(
self.command,
shell=self.shell,
cwd=self.cwd,
stdin=stdin,
stdout=stdout,
stderr=stderr,
env=environment,
)
cmd_input = None
if self.input_data is not None:
cmd_input = self.input_data
if isinstance(cmd_input, six.string_types):
cmd_input_bytes = cmd_input.encode('utf-8')
else:
cmd_input_bytes = cmd_input
cmd_output = proc.communicate(input=cmd_input_bytes)
(cmd_stdout, cmd_stderr) = cmd_output
self.output = self.sanitize_output(cmd_stdout)
self.error = self.sanitize_output(cmd_stderr)
self.exit_code = proc.returncode
except OSError:
self.error = traceback.format_exc()
self.output = self.error
self.exit_code = -1
finally:
self.end_time = datetime.utcnow()
def sanitize_output(self, output):
r"""
Sanitize ``output`` to be saved into the DB.
1. Decodes to UTF-8
2. Replaces NULL (\x00) characters with ``''`` (empty string) to
avoid PostgreSQL db to fail:
https://code.djangoproject.com/ticket/28201
:param output: stdout/stderr to be sanitized
:type output: bytes
:returns: sanitized output as string or ``None`` if it fails
"""
try:
sanitized = output.decode('utf-8', 'replace')
# Replace NULL (\x00) character to avoid PostgreSQL db to fail
# https://code.djangoproject.com/ticket/28201
sanitized = sanitized.replace('\x00', '')
except (TypeError, AttributeError):
sanitized = None
return sanitized
def get_command(self):
"""Flatten command."""
if hasattr(self.command, '__iter__') and not isinstance(self.command, str):
return ' '.join(self.command)
return self.command
def save(self):
"""Save this command and result via the API."""
# Force record this command as success to avoid Build reporting errors
# on commands that are just for checking purposes and do not interferes
# in the Build
if self.record_as_success:
log.warning('Recording command exit_code as success')
self.exit_code = 0
data = {
'build': self.build_env.build.get('id'),
'command': self.get_command(),
'description': self.description,
'output': self.output,
'exit_code': self.exit_code,
'start_time': self.start_time,
'end_time': self.end_time,
}
api_v2.command.post(data)
class DockerBuildCommand(BuildCommand):
"""
Create a docker container and run a command inside the container.
Build command to execute in docker container
"""
def run(self):
"""
Execute command in existing Docker container.
:param cmd_input: input to pass to command in STDIN
:type cmd_input: str
:param combine_output: combine STDERR into STDOUT
"""
log.info(
"Running in container %s: '%s' [%s]",
self.build_env.container_id,
self.get_command(),
self.cwd,
)
self.start_time = datetime.utcnow()
client = self.build_env.get_client()
try:
exec_cmd = client.exec_create(
container=self.build_env.container_id,
cmd=self.get_wrapped_command(),
stdout=True,
stderr=True,
)
cmd_output = client.exec_start(exec_id=exec_cmd['Id'], stream=False)
self.output = self.sanitize_output(cmd_output)
cmd_ret = client.exec_inspect(exec_id=exec_cmd['Id'])
self.exit_code = cmd_ret['ExitCode']
# Docker will exit with a special exit code to signify the command
# was killed due to memory usage, make the error code
# nicer. Sometimes the kernel kills the command and Docker doesn't
# not use the specific exit code, so we check if the word `Killed`
# is in the last 15 lines of the command's output
killed_in_output = 'Killed' in '\n'.join(self.output.splitlines()[-15:])
if self.exit_code == DOCKER_OOM_EXIT_CODE or (self.exit_code == 1 and killed_in_output):
self.output = _('Command killed due to excessive memory '
'consumption\n')
except DockerAPIError:
self.exit_code = -1
if self.output is None or not self.output:
self.output = _('Command exited abnormally')
finally:
self.end_time = datetime.utcnow()
def get_wrapped_command(self):
"""
Escape special bash characters in command to wrap in shell.
In order to set the current working path inside a docker container, we
need to wrap the command in a shell call manually. Some characters will
be interpreted as shell characters without escaping, such as: ``pip
install requests<0.8``. This escapes a good majority of those
characters.
"""
bash_escape_re = re.compile(r"([\t\ \!\"\#\$\&\'\(\)\*\:\;\<\>\?\@"
r"\[\\\]\^\`\{\|\}\~])")
prefix = ''
if self.bin_path:
prefix += 'PATH={0}:$PATH '.format(self.bin_path)
return ("/bin/sh -c 'cd {cwd} && {prefix}{cmd}'"
.format(
cwd=self.cwd,
prefix=prefix,
cmd=(' '.join([bash_escape_re.sub(r'\\\1', part)
for part in self.command]))))
class BaseEnvironment(object):
"""
Base environment class.
Used to run arbitrary commands outside a build.
"""
def __init__(self, project, environment=None):
# TODO: maybe we can remove this Project dependency also
self.project = project
self.environment = environment or {}
self.commands = []
def record_command(self, command):
pass
def run(self, *cmd, **kwargs):
"""Shortcut to run command from environment."""
return self.run_command_class(cls=self.command_class, cmd=cmd, **kwargs)
def run_command_class(
self, cls, cmd, record=None, warn_only=False,
record_as_success=False, **kwargs):
"""
Run command from this environment.
:param cls: command class to instantiate a command
:param cmd: command (as a list) to execute in this environment
:param record: whether or not to record this particular command
(``False`` implies ``warn_only=True``)
:param warn_only: don't raise an exception on command failure
:param record_as_success: force command ``exit_code`` to be saved as
``0`` (``True`` implies ``warn_only=True`` and ``record=True``)
"""
if record is None:
# ``self.record`` only exists when called from ``*BuildEnvironment``
record = getattr(self, 'record', False)
if not record:
warn_only = True
if record_as_success:
record = True
warn_only = True
# ``record_as_success`` is needed to instantiate the BuildCommand
kwargs.update({'record_as_success': record_as_success})
# Remove PATH from env, and set it to bin_path if it isn't passed in
env_path = self.environment.pop('BIN_PATH', None)
if 'bin_path' not in kwargs and env_path:
kwargs['bin_path'] = env_path
assert 'environment' not in kwargs, "environment can't be passed in via commands."
kwargs['environment'] = self.environment
# ``build_env`` is passed as ``kwargs`` when it's called from a
# ``*BuildEnvironment``
build_cmd = cls(cmd, **kwargs)
build_cmd.run()
if record:
# TODO: I don't like how it's handled this entry point here since
# this class should know nothing about a BuildCommand (which are the
# only ones that can be saved/recorded)
self.record_command(build_cmd)
# We want append this command to the list of commands only if it has
# to be recorded in the database (to keep consistency) and also, it
# has to be added after ``self.record_command`` since its
# ``exit_code`` can be altered because of ``record_as_success``
self.commands.append(build_cmd)
if build_cmd.failed:
msg = u'Command {cmd} failed'.format(cmd=build_cmd.get_command())
if build_cmd.output:
msg += u':\n{out}'.format(out=build_cmd.output)
if warn_only:
log.warning(LOG_TEMPLATE.format(
project=self.project.slug,
version='latest',
msg=msg,
))
else:
raise BuildEnvironmentWarning(msg)
return build_cmd
class LocalEnvironment(BaseEnvironment):
# TODO: BuildCommand name doesn't make sense here, should be just Command
command_class = BuildCommand
class BuildEnvironment(BaseEnvironment):
"""
Base build environment.
Base class for wrapping command execution for build steps. This provides a
context for command execution and reporting, and eventually performs updates
on the build object itself, reporting success/failure, as well as failures
during the context manager enter and exit.
Any exceptions raised inside this context and handled by the eventual
:py:meth:`__exit__` method, specifically, inside :py:meth:`handle_exception`
and :py:meth:`update_build`. If the exception is a subclass of
:py:class:`BuildEnvironmentError`, then this error message is added to the
build object and is shown to the user as the top-level failure reason for
why the build failed. Other exceptions raise a general failure warning on
the build.
We only update the build through the API in one of three cases:
* The build is not done and needs an additional build step to follow
* The build failed and we should always report this change
* The build was successful and ``update_on_success`` is ``True``
:param project: Project that is being built
:param version: Project version that is being built
:param build: Build instance
:param record: Record status of build object
:param environment: shell environment variables
:param update_on_success: update the build object via API if the build was
successful
"""
# Exceptions considered ERROR from a Build perspective but as a WARNING for
# the application itself. These exception are logged as warning and not sent
# to Sentry.
WARNING_EXCEPTIONS = (
VersionLockedError,
ProjectBuildsSkippedError,
YAMLParseError,
BuildTimeoutError,
)
def __init__(self, project=None, version=None, build=None, config=None,
record=True, environment=None, update_on_success=True):
super(BuildEnvironment, self).__init__(project, environment)
self.version = version
self.build = build
self.config = config
self.record = record
self.update_on_success = update_on_success
self.failure = None
self.start_time = datetime.utcnow()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, tb):
ret = self.handle_exception(exc_type, exc_value, tb)
self.update_build(BUILD_STATE_FINISHED)
log.info(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg='Build finished',
)
)
return ret
def handle_exception(self, exc_type, exc_value, _):
"""
Exception handling for __enter__ and __exit__.
This reports on the exception we're handling and special cases
subclasses of BuildEnvironmentException. For
:py:class:`BuildEnvironmentWarning`, exit this context gracefully, but
don't mark the build as a failure. For all other exception classes,
including :py:class:`BuildEnvironmentError`, the build will be marked as
a failure and the context will be gracefully exited.
If the exception's type is :py:class:`BuildEnvironmentWarning` or it's
an exception marked as ``WARNING_EXCEPTIONS`` we log the problem as a
WARNING, otherwise we log it as an ERROR.
"""
if exc_type is not None:
log_level_function = None
if issubclass(exc_type, BuildEnvironmentWarning):
log_level_function = log.warning
elif exc_type in self.WARNING_EXCEPTIONS:
log_level_function = log.warning
self.failure = exc_value
else:
log_level_function = log.error
self.failure = exc_value
log_level_function(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg=exc_value,
),
exc_info=True,
extra={
'stack': True,
'tags': {
'build': self.build.get('id'),
'project': self.project.slug,
'version': self.version.slug,
},
},
)
return True
def record_command(self, command):
command.save()
def run(self, *cmd, **kwargs):
kwargs.update({
'build_env': self,
})
return super(BuildEnvironment, self).run(*cmd, **kwargs)
def run_command_class(self, *cmd, **kwargs): # pylint: disable=arguments-differ
kwargs.update({
'build_env': self,
})
return super(BuildEnvironment, self).run_command_class(*cmd, **kwargs)
@property
def successful(self):
"""Is build completed, without top level failures or failing commands.""" # noqa
return (self.done and self.failure is None and
all(cmd.successful for cmd in self.commands))
@property
def failed(self):
"""Is build completed, but has top level failure or failing commands."""
return (self.done and (
self.failure is not None or
any(cmd.failed for cmd in self.commands)
))
@property
def done(self):
"""Is build in finished state."""
return (self.build is not None and
self.build['state'] == BUILD_STATE_FINISHED)
def update_build(self, state=None):
"""
Record a build by hitting the API.
This step is skipped if we aren't recording the build. To avoid
recording successful builds yet (for instance, running setup commands
for the build), set the ``update_on_success`` argument to False on
environment instantiation.
If there was an error on the build, update the build regardless of
whether ``update_on_success`` is ``True`` or not.
"""
if not self.record:
return None
self.build['project'] = self.project.pk
self.build['version'] = self.version.pk
self.build['builder'] = socket.gethostname()
self.build['state'] = state
if self.done:
self.build['success'] = self.successful
# TODO drop exit_code and provide a more meaningful UX for error
# reporting
if self.failure and isinstance(
self.failure,
BuildEnvironmentException,
):
self.build['exit_code'] = self.failure.status_code
elif self.commands:
self.build['exit_code'] = max([
cmd.exit_code for cmd in self.commands
])
self.build['setup'] = self.build['setup_error'] = ''
self.build['output'] = self.build['error'] = ''
if self.start_time:
build_length = (datetime.utcnow() - self.start_time)
self.build['length'] = int(build_length.total_seconds())
if self.failure is not None:
# Surface a generic error if the class is not a
# BuildEnvironmentError
if not isinstance(
self.failure,
(
BuildEnvironmentException,
BuildEnvironmentWarning,
),
):
log.error(
'Build failed with unhandled exception: %s',
str(self.failure),
extra={
'stack': True,
'tags': {
'build': self.build.get('id'),
'project': self.project.slug,
'version': self.version.slug,
},
},
)
self.failure = BuildEnvironmentError(
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
build_id=self.build['id'],
),
)
self.build['error'] = str(self.failure)
# Attempt to stop unicode errors on build reporting
for key, val in list(self.build.items()):
if isinstance(val, six.binary_type):
self.build[key] = val.decode('utf-8', 'ignore')
# We are selective about when we update the build object here
update_build = (
# Build isn't done yet, we unconditionally update in this state
not self.done
# Build is done, but isn't successful, always update
or (self.done and not self.successful)
# Otherwise, are we explicitly to not update?
or self.update_on_success
)
if update_build:
try:
api_v2.build(self.build['id']).put(self.build)
except HttpClientError as e:
log.exception(
'Unable to update build: id=%d',
self.build['id'],
)
except Exception:
log.exception('Unknown build exception')
class LocalBuildEnvironment(BuildEnvironment):
"""Local execution build environment."""
command_class = BuildCommand
class DockerBuildEnvironment(BuildEnvironment):
"""
Docker build environment, uses docker to contain builds.
If :py:data:`settings.DOCKER_ENABLE` is true, build documentation inside a
docker container, instead of the host system, using this build environment
class. The build command creates a docker container from a pre-built image,
defined by :py:data:`settings.DOCKER_IMAGE`. This container is started with
a mount to the project's build path under ``user_builds`` on the host
machine, walling off project builds from reading/writing other projects'
data.
:param docker_socket: Override to Docker socket URI
"""
command_class = DockerBuildCommand
container_image = DOCKER_IMAGE
container_mem_limit = DOCKER_LIMITS.get('memory')
container_time_limit = DOCKER_LIMITS.get('time')
def __init__(self, *args, **kwargs):
self.docker_socket = kwargs.pop('docker_socket', DOCKER_SOCKET)
super(DockerBuildEnvironment, self).__init__(*args, **kwargs)
self.client = None
self.container = None
self.container_name = slugify(
'build-{build}-project-{project_id}-{project_name}'.format(
build=self.build.get('id'),
project_id=self.project.pk,
project_name=self.project.slug,
)[:DOCKER_HOSTNAME_MAX_LEN],
)
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:
self.container_time_limit = self.project.container_time_limit
def __enter__(self):
"""Start of environment context."""
try:
# Test for existing container. We remove any stale containers that
# are no longer running here if there is a collision. If the
# container is still running, this would be a failure of the version
# locking code, so we throw an exception.
state = self.container_state()
if state is not None:
if state.get('Running') is True:
exc = BuildEnvironmentError(
_(
'A build environment is currently '
'running for this version',
),
)
self.failure = exc
self.build['state'] = BUILD_STATE_FINISHED
raise exc
else:
log.warning(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg=(
'Removing stale container {0}'
.format(self.container_id)
),
)
)
client = self.get_client()
client.remove_container(self.container_id)
except (DockerAPIError, ConnectionError):
# If there is an exception here, we swallow the exception as this
# was just during a sanity check anyways.
pass
except BuildEnvironmentError:
# There may have been a problem connecting to Docker altogether, or
# some other handled exception here.
self.__exit__(*sys.exc_info())
raise
# Create the checkout path if it doesn't exist to avoid Docker creation
if not os.path.exists(self.project.doc_path):
os.makedirs(self.project.doc_path)
try:
self.create_container()
except: # noqa
self.__exit__(*sys.exc_info())
raise
return self
def __exit__(self, exc_type, exc_value, tb):
"""End of environment context."""
try:
# Update buildenv state given any container error states first
self.update_build_from_container_state()
client = self.get_client()
try:
client.kill(self.container_id)
except DockerAPIError:
log.exception(
'Unable to kill container: id=%s',
self.container_id,
)
try:
log.info('Removing container: id=%s', self.container_id)
client.remove_container(self.container_id)
# Catch direct failures from Docker API or with a requests HTTP
# request. These errors should not surface to the user.
except (DockerAPIError, ConnectionError):
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg="Couldn't remove container",
),
)
self.container = None
except BuildEnvironmentError:
# Several interactions with Docker can result in a top level failure
# here. We'll catch this and report if there were no reported errors
# already. These errors are not as important as a failure at deeper
# code
if not all([exc_type, exc_value, tb]):
exc_type, exc_value, tb = sys.exc_info()
return super(DockerBuildEnvironment, self).__exit__(exc_type, exc_value, tb)
def get_client(self):
"""Create Docker client connection."""
try:
if self.client is None:
self.client = APIClient(
base_url=self.docker_socket,
version=DOCKER_VERSION,
)
return self.client
except DockerException as e:
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg='Could not connect to Docker API',
),
)
# We don't raise an error here mentioning Docker, that is a
# technical detail that the user can't resolve on their own.
# Instead, give the user a generic failure
raise BuildEnvironmentError(
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
build_id=self.build['id'],
),
)
def get_container_host_config(self):
"""
Create the ``host_config`` settings for the container.
It mainly generates the proper path bindings between the Docker
container and the Host by mounting them with the proper permissions.
Besides, it mounts the ``GLOBAL_PIP_CACHE`` if it's set and we are under
``DEBUG``.
The object returned is passed to Docker function
``client.create_container``.
"""
binds = {
SPHINX_TEMPLATE_DIR: {
'bind': SPHINX_TEMPLATE_DIR,
'mode': 'ro',
},
MKDOCS_TEMPLATE_DIR: {
'bind': MKDOCS_TEMPLATE_DIR,
'mode': 'ro',
},
self.project.doc_path: {
'bind': self.project.doc_path,
'mode': 'rw',
},
}
if getattr(settings, 'GLOBAL_PIP_CACHE', False) and settings.DEBUG:
binds.update({
self.project.pip_cache_path: {
'bind': self.project.pip_cache_path,
'mode': 'rw',
},
})
return self.get_client().create_host_config(
binds=binds,
mem_limit=self.container_mem_limit,
)
@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."""
if self.container_name:
return self.container_name
if self.container:
return self.container.get('Id')
def container_state(self):
"""Get container state."""
client = self.get_client()
try:
info = client.inspect_container(self.container_id)
return info.get('State', {})
except DockerAPIError:
return None
def update_build_from_container_state(self):
"""
Update buildenv state from container state.
In the case of the parent command exiting before the exec commands
finish and the container is destroyed, or in the case of OOM on the
container, set a failure state and error message explaining the failure
on the buildenv.
"""
state = self.container_state()
if state is not None and state.get('Running') is False:
if state.get('ExitCode') == DOCKER_TIMEOUT_EXIT_CODE:
self.failure = BuildEnvironmentError(
_('Build exited due to time out'),
)
elif state.get('OOMKilled', False):
self.failure = BuildEnvironmentError(
_('Build exited due to excessive memory consumption'),
)
elif state.get('Error'):
self.failure = BuildEnvironmentError((
_('Build exited due to unknown error: {0}')
.format(state.get('Error'))
),
)
def create_container(self):
"""Create docker container."""
client = self.get_client()
try:
log.info(
'Creating Docker container: image=%s',
self.container_image,
)
self.container = client.create_container(
image=self.container_image,
command=(
'/bin/sh -c "sleep {time}; exit {exit}"'.format(
time=self.container_time_limit,
exit=DOCKER_TIMEOUT_EXIT_CODE,
)
),
name=self.container_id,
hostname=self.container_id,
host_config=self.get_container_host_config(),
detach=True,
environment=self.environment,
)
client.start(container=self.container_id)
except ConnectionError as e:
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg=(
'Could not connect to the Docker API, '
'make sure Docker is running'
),
),
)
# We don't raise an error here mentioning Docker, that is a
# technical detail that the user can't resolve on their own.
# Instead, give the user a generic failure
raise BuildEnvironmentError(
BuildEnvironmentError.GENERIC_WITH_BUILD_ID.format(
build_id=self.build['id'],
),
)
except DockerAPIError as e:
log.exception(
LOG_TEMPLATE.format(
project=self.project.slug,
version=self.version.slug,
msg=e.explanation,
),
)
raise BuildEnvironmentCreationFailed