23
23
from readthedocs .builds .models import BuildCommandResultMixin
24
24
from readthedocs .projects .constants import LOG_TEMPLATE
25
25
from readthedocs .restapi .client import api as api_v2
26
+ from requests .exceptions import ConnectionError
26
27
27
28
from .exceptions import (BuildEnvironmentException , BuildEnvironmentError ,
28
- BuildEnvironmentWarning )
29
+ BuildEnvironmentWarning , BuildEnvironmentCreationFailed )
29
30
from .constants import (DOCKER_SOCKET , DOCKER_VERSION , DOCKER_IMAGE ,
30
31
DOCKER_LIMITS , DOCKER_TIMEOUT_EXIT_CODE ,
31
32
DOCKER_OOM_EXIT_CODE , SPHINX_TEMPLATE_DIR ,
@@ -267,23 +268,40 @@ class BuildEnvironment(object):
267
268
268
269
Base class for wrapping command execution for build steps. This provides a
269
270
context for command execution and reporting, and eventually performs updates
270
- on the build object itself, reporting success/failure, as well as top-level
271
- failures.
271
+ on the build object itself, reporting success/failure, as well as failures
272
+ during the context manager enter and exit.
273
+
274
+ Any exceptions raised inside this context and handled by the eventual
275
+ :py:meth:`__exit__` method, specifically, inside :py:meth:`handle_exception`
276
+ and :py:meth:`update_build`. If the exception is a subclass of
277
+ :py:cls:`BuildEnvironmentError`, then this error message is added to the
278
+ build object and is shown to the user as the top-level failure reason for
279
+ why the build failed. Other exceptions raise a general failure warning on
280
+ the build.
281
+
282
+ We only update the build through the API in one of three cases:
283
+
284
+ * The build is not done and needs an additional build step to follow
285
+ * The build failed and we should always report this change
286
+ * The build was successful and ``update_on_success`` is ``True``
272
287
273
288
:param project: Project that is being built
274
289
:param version: Project version that is being built
275
290
:param build: Build instance
276
291
:param record: Record status of build object
277
292
:param environment: shell environment variables
293
+ :param update_on_success: update the build object via API if the build was
294
+ successful
278
295
"""
279
296
280
297
def __init__ (self , project = None , version = None , build = None , record = True ,
281
- environment = None ):
298
+ environment = None , update_on_success = True ):
282
299
self .project = project
283
300
self .version = version
284
301
self .build = build
285
302
self .record = record
286
303
self .environment = environment or {}
304
+ self .update_on_success = update_on_success
287
305
288
306
self .commands = []
289
307
self .failure = None
@@ -294,7 +312,7 @@ def __enter__(self):
294
312
295
313
def __exit__ (self , exc_type , exc_value , tb ):
296
314
ret = self .handle_exception (exc_type , exc_value , tb )
297
- self .build [ 'state' ] = BUILD_STATE_FINISHED
315
+ self .update_build ( BUILD_STATE_FINISHED )
298
316
log .info (LOG_TEMPLATE
299
317
.format (project = self .project .slug ,
300
318
version = self .version .slug ,
@@ -386,9 +404,13 @@ def done(self):
386
404
def update_build (self , state = None ):
387
405
"""Record a build by hitting the API
388
406
389
- This step is skipped if we aren't recording the build, or if we don't
390
- want to record successful builds yet (if we are running setup commands
391
- for the build)
407
+ This step is skipped if we aren't recording the build. To avoid
408
+ recording successful builds yet (for instance, running setup commands
409
+ for the build), set the ``update_on_success`` argument to False on
410
+ environment instantiation.
411
+
412
+ If there was an error on the build, update the build regardless of
413
+ whether ``update_on_success`` is ``True`` or not.
392
414
"""
393
415
if not self .record :
394
416
return None
@@ -417,37 +439,51 @@ def update_build(self, state=None):
417
439
self .build ['length' ] = int (build_length .total_seconds ())
418
440
419
441
if self .failure is not None :
420
- # Only surface the error message if it was a
421
- # BuildEnvironmentException or BuildEnvironmentWarning
422
- if isinstance (self .failure ,
423
- (BuildEnvironmentException , BuildEnvironmentWarning )):
424
- self .build ['error' ] = str (self .failure )
425
- else :
426
- self .build ['error' ] = ugettext_noop (
427
- "There was a problem with Read the Docs while building your documentation. "
428
- "Please report this to us with your build id ({build_id})." .format (
429
- build_id = self .build ['id' ]
430
- )
431
- )
442
+ # Surface a generic error if the class is not a
443
+ # BuildEnvironmentError
444
+ if not isinstance (self .failure ,
445
+ (BuildEnvironmentException ,
446
+ BuildEnvironmentWarning )):
432
447
log .error (
433
448
'Build failed with unhandled exception: %s' ,
434
449
str (self .failure ),
435
- extra = {'stack' : True ,
436
- 'tags' : {'build' : self .build ['id' ]},
437
- }
450
+ extra = {
451
+ 'stack' : True ,
452
+ 'tags' : {'build' : self .build ['id' ]},
453
+ }
454
+ )
455
+ self .failure = BuildEnvironmentError (
456
+ BuildEnvironmentError .GENERIC_WITH_BUILD_ID .format (
457
+ build_id = self .build ['id' ],
458
+ )
438
459
)
460
+ self .build ['error' ] = str (self .failure )
439
461
440
462
# Attempt to stop unicode errors on build reporting
441
463
for key , val in list (self .build .items ()):
442
464
if isinstance (val , six .binary_type ):
443
465
self .build [key ] = val .decode ('utf-8' , 'ignore' )
444
466
445
- try :
446
- api_v2 .build (self .build ['id' ]).put (self .build )
447
- except HttpClientError as e :
448
- log .error ("Unable to post a new build: %s" , e .content )
449
- except Exception :
450
- log .exception ("Unknown build exception" )
467
+ # We are selective about when we update the build object here
468
+ update_build = (
469
+ # Build isn't done yet, we unconditionally update in this state
470
+ not self .done
471
+ # Build is done, but isn't successful, always update
472
+ or (self .done and not self .successful )
473
+ # Otherwise, are we explicitly to not update?
474
+ or self .update_on_success
475
+ )
476
+ if update_build :
477
+ try :
478
+ api_v2 .build (self .build ['id' ]).put (self .build )
479
+ except HttpClientError as e :
480
+ log .error (
481
+ "Unable to update build: id=%d error=%s" ,
482
+ self .build ['id' ],
483
+ e .content ,
484
+ )
485
+ except Exception :
486
+ log .exception ("Unknown build exception" )
451
487
452
488
453
489
class LocalEnvironment (BuildEnvironment ):
@@ -521,8 +557,15 @@ def __enter__(self):
521
557
.format (self .container_id ))))
522
558
client = self .get_client ()
523
559
client .remove_container (self .container_id )
524
- except DockerAPIError :
560
+ except (DockerAPIError , ConnectionError ):
561
+ # If there is an exception here, we swallow the exception as this
562
+ # was just during a sanity check anyways.
525
563
pass
564
+ except BuildEnvironmentError :
565
+ # There may have been a problem connecting to Docker altogether, or
566
+ # some other handled exception here.
567
+ self .__exit__ (* sys .exc_info ())
568
+ raise
526
569
527
570
# Create the checkout path if it doesn't exist to avoid Docker creation
528
571
if not os .path .exists (self .project .doc_path ):
@@ -537,28 +580,43 @@ def __enter__(self):
537
580
538
581
def __exit__ (self , exc_type , exc_value , tb ):
539
582
"""End of environment context"""
540
- ret = self .handle_exception (exc_type , exc_value , tb )
583
+ try :
584
+ # Update buildenv state given any container error states first
585
+ self .update_build_from_container_state ()
541
586
542
- # Update buildenv state given any container error states first
543
- self .update_build_from_container_state ()
587
+ client = self .get_client ()
588
+ try :
589
+ client .kill (self .container_id )
590
+ except DockerAPIError :
591
+ log .exception (
592
+ 'Unable to kill container: id=%s' ,
593
+ self .container_id ,
594
+ )
595
+ try :
596
+ log .info ('Removing container: id=%s' , self .container_id )
597
+ client .remove_container (self .container_id )
598
+ # Catch direct failures from Docker API or with a requests HTTP
599
+ # request. These errors should not surface to the user.
600
+ except (DockerAPIError , ConnectionError ):
601
+ log .exception (
602
+ LOG_TEMPLATE
603
+ .format (
604
+ project = self .project .slug ,
605
+ version = self .version .slug ,
606
+ msg = "Couldn't remove container" ,
607
+ ),
608
+ )
609
+ self .container = None
610
+ except BuildEnvironmentError :
611
+ # Several interactions with Docker can result in a top level failure
612
+ # here. We'll catch this and report if there were no reported errors
613
+ # already. These errors are not as important as a failure at deeper
614
+ # code
615
+ if not all ([exc_type , exc_value , tb ]):
616
+ exc_type , exc_value , tb = sys .exc_info ()
544
617
545
- client = self .get_client ()
546
- try :
547
- client .kill (self .container_id )
548
- except DockerAPIError :
549
- pass
550
- try :
551
- log .info ('Removing container %s' , self .container_id )
552
- client .remove_container (self .container_id )
553
- except DockerAPIError :
554
- log .error (LOG_TEMPLATE
555
- .format (
556
- project = self .project .slug ,
557
- version = self .version .slug ,
558
- msg = "Couldn't remove container" ),
559
- exc_info = True )
560
- self .container = None
561
- self .build ['state' ] = BUILD_STATE_FINISHED
618
+ ret = self .handle_exception (exc_type , exc_value , tb )
619
+ self .update_build (BUILD_STATE_FINISHED )
562
620
log .info (LOG_TEMPLATE
563
621
.format (project = self .project .slug ,
564
622
version = self .version .slug ,
@@ -576,13 +634,21 @@ def get_client(self):
576
634
)
577
635
return self .client
578
636
except DockerException as e :
579
- log .error (LOG_TEMPLATE
580
- .format (
581
- project = self .project .slug ,
582
- version = self .version .slug ,
583
- msg = e ),
584
- exc_info = True )
585
- raise BuildEnvironmentError ('Problem creating build environment' )
637
+ log .exception (
638
+ LOG_TEMPLATE .format (
639
+ project = self .project .slug ,
640
+ version = self .version .slug ,
641
+ msg = 'Could not connect to Docker API' ,
642
+ ),
643
+ )
644
+ # We don't raise an error here mentioning Docker, that is a
645
+ # technical detail that the user can't resolve on their own.
646
+ # Instead, give the user a generic failure
647
+ raise BuildEnvironmentError (
648
+ BuildEnvironmentError .GENERIC_WITH_BUILD_ID .format (
649
+ build_id = self .build ['id' ],
650
+ )
651
+ )
586
652
587
653
@property
588
654
def container_id (self ):
@@ -655,11 +721,32 @@ def create_container(self):
655
721
mem_limit = self .container_mem_limit ,
656
722
)
657
723
client .start (container = self .container_id )
724
+ except ConnectionError as e :
725
+ log .exception (
726
+ LOG_TEMPLATE .format (
727
+ project = self .project .slug ,
728
+ version = self .version .slug ,
729
+ msg = (
730
+ 'Could not connect to the Docker API, '
731
+ 'make sure Docker is running'
732
+ ),
733
+ ),
734
+ )
735
+ # We don't raise an error here mentioning Docker, that is a
736
+ # technical detail that the user can't resolve on their own.
737
+ # Instead, give the user a generic failure
738
+ raise BuildEnvironmentError (
739
+ BuildEnvironmentError .GENERIC_WITH_BUILD_ID .format (
740
+ build_id = self .build ['id' ],
741
+ )
742
+ )
658
743
except DockerAPIError as e :
659
- log .error (LOG_TEMPLATE
660
- .format (
661
- project = self .project .slug ,
662
- version = self .version .slug ,
663
- msg = e .explanation ),
664
- exc_info = True )
665
- raise BuildEnvironmentError ('Build environment creation failed' )
744
+ log .exception (
745
+ LOG_TEMPLATE
746
+ .format (
747
+ project = self .project .slug ,
748
+ version = self .version .slug ,
749
+ msg = e .explanation ,
750
+ ),
751
+ )
752
+ raise BuildEnvironmentCreationFailed
0 commit comments